feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -267,6 +267,13 @@
|
|||||||
"title": "CSS reuse-first: prefer existing classes over new rules",
|
"title": "CSS reuse-first: prefer existing classes over new rules",
|
||||||
"requirement": "MUST check existing CSS layers (base, components, layout, pages) for suitable classes before writing new rules. MUST use existing app-* component classes as the basis and only add additive overrides. MUST NOT duplicate declarations already defined in shared component or layout files.",
|
"requirement": "MUST check existing CSS layers (base, components, layout, pages) for suitable classes before writing new rules. MUST use existing app-* component classes as the basis and only add additive overrides. MUST NOT duplicate declarations already defined in shared component or layout files.",
|
||||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GR-UI-017",
|
||||||
|
"area": "ui",
|
||||||
|
"title": "Component lifecycle contract for migrated UI modules",
|
||||||
|
"requirement": "Components migrated to the lifecycle runtime MUST expose an init(root, config) entrypoint that returns an API with destroy(). Migrated component modules MUST NOT auto-initialize themselves via DOMContentLoaded/module side effects and MUST NOT retain legacy wrapper entrypoints or init alias exports. Event listeners, timers, and observers created during init MUST be cleaned up in destroy(). Runtime configuration MUST come from page-config payloads (readPageConfig) instead of scattered ad-hoc globals. Page-level initialization MUST be loaded via web/js/pages/* entry modules, not js/components/* script loads. Host-bound UI components (including tabs) MUST provide explicit data-app-component hosts in markup and remain root-strict (no document.querySelector fallback paths). Global utilities without a natural host MAY be runtime-registered with scope=global and MUST still satisfy the same lifecycle/destroy contract. Project-specific custom window.* globals are forbidden; cross-component communication MUST use core channel modules. Migrated UI storage MUST use the shared namespaced storage contract without legacy key fallback/sync paths.",
|
||||||
|
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Canonical guard IDs live in `agent-system/checks/guard-catalog.json`.
|
|||||||
- `GR-UI-015` new t() keys present in all language files (de + en) in the same merge; no key missing in any language file
|
- `GR-UI-015` new t() keys present in all language files (de + en) in the same merge; no key missing in any language file
|
||||||
- `GR-UI-011` JS reuse-first: existing exports in web/js/core/ and web/js/components/ checked before adding a new function; no logic duplication from core modules
|
- `GR-UI-011` JS reuse-first: existing exports in web/js/core/ and web/js/components/ checked before adding a new function; no logic duplication from core modules
|
||||||
- `GR-UI-012` CSS reuse-first: existing app-* classes and layer rules checked before writing new declarations; only additive overrides on top of shared components
|
- `GR-UI-012` CSS reuse-first: existing app-* classes and layer rules checked before writing new declarations; only additive overrides on top of shared components
|
||||||
|
- `GR-UI-017` lifecycle runtime contract for migrated components is enforced (init(root, config) + destroy(), no component-level auto-init side effects, no legacy wrappers/init alias exports, cleanup of listeners/timers/observers, runtime config via page-config, page-level JS only via `web/js/pages/*` modules, host-bound components incl. tabs with `data-app-component` and root-strict selectors, global utilities only via explicit runtime-global registration, no project-specific custom `window.*` globals, namespaced UI storage without legacy fallback/sync paths)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- `GR-TEST-001` new or changed Service/Gateway logic has accompanying PHPUnit tests (happy path + at least one failure/edge case) in the same merge
|
- `GR-TEST-001` new or changed Service/Gateway logic has accompanying PHPUnit tests (happy path + at least one failure/edge case) in the same merge
|
||||||
|
|||||||
80
bin/module-assets-sync.php
Normal file
80
bin/module-assets-sync.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes enabled module web assets into web/modules/.
|
||||||
|
*
|
||||||
|
* Usage: php bin/module-assets-sync.php
|
||||||
|
*
|
||||||
|
* APP_MODULE_ASSET_MODE:
|
||||||
|
* - symlink (default): create web/modules/<module-id> symlinks to modules/<id>/web
|
||||||
|
* - copy: copy module web directories (for environments without symlink support)
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — sync successful
|
||||||
|
* 1 — error during sync
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
|
|
||||||
|
function moduleAssetsSyncRun(): int
|
||||||
|
{
|
||||||
|
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
moduleCliBootstrapApp();
|
||||||
|
|
||||||
|
$exitCode = 0;
|
||||||
|
$runtimeModulesDir = __DIR__ . '/../web/modules';
|
||||||
|
$lockFile = __DIR__ . '/../storage/runtime/.module-assets-sync.lock';
|
||||||
|
$mode = (string) getenv('APP_MODULE_ASSET_MODE');
|
||||||
|
if (trim($mode) === '') {
|
||||||
|
$mode = 'symlink';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lockDir = dirname($lockFile);
|
||||||
|
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
||||||
|
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = fopen($lockFile, 'c');
|
||||||
|
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||||
|
fwrite(STDERR, "module-assets-sync: another sync is in progress, skipping.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = app(ModuleRegistry::class);
|
||||||
|
$publisher = new ModuleRuntimeAssetPublisher();
|
||||||
|
$result = $publisher->publish($runtimeModulesDir, $registry->getModules(), $mode);
|
||||||
|
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
||||||
|
$mode,
|
||||||
|
$result['created'],
|
||||||
|
$result['updated'],
|
||||||
|
$result['removed'],
|
||||||
|
$result['unchanged']
|
||||||
|
));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, sprintf("module-assets-sync: FAILED: %s\n", $exception->getMessage()));
|
||||||
|
$exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (isset($lock) && is_resource($lock)) {
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||||
|
fwrite(STDOUT, sprintf("module-assets-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||||
|
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
|
exit(moduleAssetsSyncRun());
|
||||||
|
}
|
||||||
@@ -18,159 +18,64 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
chdir(__DIR__ . '/..');
|
|
||||||
|
|
||||||
require 'vendor/autoload.php';
|
|
||||||
require 'config/config.php';
|
|
||||||
require 'lib/Support/helpers.php';
|
|
||||||
$container = require 'lib/App/registerContainer.php';
|
|
||||||
setAppContainer($container);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||||
|
|
||||||
$exitCode = 0;
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
|
||||||
|
|
||||||
try {
|
function moduleBuildRun(): int
|
||||||
// Ensure runtime directory exists
|
|
||||||
$runtimeParent = dirname($runtimeDir);
|
|
||||||
if (!is_dir($runtimeParent)) {
|
|
||||||
mkdir($runtimeParent, 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock to prevent concurrent builds
|
|
||||||
$lock = fopen($lockFile, 'c');
|
|
||||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
|
||||||
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$registry = app(ModuleRegistry::class);
|
|
||||||
$modules = $registry->getModules();
|
|
||||||
|
|
||||||
$corePages = realpath(__DIR__ . '/../pages');
|
|
||||||
if ($corePages === false) {
|
|
||||||
throw new RuntimeException('Core pages/ directory not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no modules, ensure runtime dir points to core pages
|
|
||||||
if (count($modules) === 0) {
|
|
||||||
// Remove existing runtime dir if it's a real directory (not a symlink)
|
|
||||||
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
|
||||||
removeDirectoryContents($runtimeDir);
|
|
||||||
rmdir($runtimeDir);
|
|
||||||
}
|
|
||||||
// Symlink runtime → core pages
|
|
||||||
if (is_link($runtimeDir)) {
|
|
||||||
unlink($runtimeDir);
|
|
||||||
}
|
|
||||||
symlink($corePages, $runtimeDir);
|
|
||||||
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// With modules: build merged directory
|
|
||||||
if (is_link($runtimeDir)) {
|
|
||||||
unlink($runtimeDir);
|
|
||||||
}
|
|
||||||
if (!is_dir($runtimeDir)) {
|
|
||||||
mkdir($runtimeDir, 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean existing symlinks in runtime dir
|
|
||||||
removeDirectoryContents($runtimeDir);
|
|
||||||
|
|
||||||
// Symlink all core page directories/files
|
|
||||||
$coreEntries = scandir($corePages);
|
|
||||||
if ($coreEntries === false) {
|
|
||||||
throw new RuntimeException('Cannot scan core pages/ directory.');
|
|
||||||
}
|
|
||||||
foreach ($coreEntries as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$source = $corePages . '/' . $entry;
|
|
||||||
$target = $runtimeDir . '/' . $entry;
|
|
||||||
symlink($source, $target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add module page symlinks (fail-fast on collision)
|
|
||||||
$modulePageCount = 0;
|
|
||||||
foreach ($modules as $manifest) {
|
|
||||||
$modulePagesDir = $manifest->basePath . '/pages';
|
|
||||||
if (!is_dir($modulePagesDir)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entries = scandir($modulePagesDir);
|
|
||||||
if ($entries === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$source = $modulePagesDir . '/' . $entry;
|
|
||||||
$target = $runtimeDir . '/' . $entry;
|
|
||||||
|
|
||||||
if (file_exists($target) || is_link($target)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
symlink($source, $target);
|
|
||||||
$modulePageCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-build: runtime pages built — %d core entries + %d module entries.\n",
|
|
||||||
count($coreEntries) - 2, // minus . and ..
|
|
||||||
$modulePageCount
|
|
||||||
));
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
|
|
||||||
$exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
if (isset($lock) && is_resource($lock)) {
|
|
||||||
flock($lock, LOCK_UN);
|
|
||||||
fclose($lock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit($exitCode);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all files and symlinks in a directory (non-recursive, top-level only).
|
|
||||||
*/
|
|
||||||
function removeDirectoryContents(string $dir): void
|
|
||||||
{
|
{
|
||||||
$entries = scandir($dir);
|
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||||
if ($entries === false) {
|
moduleCliBootstrapApp();
|
||||||
return;
|
|
||||||
}
|
$exitCode = 0;
|
||||||
foreach ($entries as $entry) {
|
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
||||||
if ($entry === '.' || $entry === '..') {
|
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
||||||
continue;
|
|
||||||
|
try {
|
||||||
|
// Ensure runtime directory exists
|
||||||
|
$runtimeParent = dirname($runtimeDir);
|
||||||
|
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
||||||
|
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
|
||||||
}
|
}
|
||||||
$path = $dir . '/' . $entry;
|
|
||||||
if (is_link($path)) {
|
// Lock to prevent concurrent builds
|
||||||
unlink($path);
|
$lock = fopen($lockFile, 'c');
|
||||||
} elseif (is_dir($path)) {
|
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||||
// Recursively remove directories that were created (shouldn't happen in normal flow)
|
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
|
||||||
$items = new RecursiveIteratorIterator(
|
return 0;
|
||||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
}
|
||||||
RecursiveIteratorIterator::CHILD_FIRST
|
|
||||||
);
|
$registry = app(ModuleRegistry::class);
|
||||||
foreach ($items as $item) {
|
$modules = $registry->getModules();
|
||||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
$builder = new ModuleRuntimePageBuilder();
|
||||||
}
|
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
|
||||||
rmdir($path);
|
|
||||||
|
if (count($modules) === 0) {
|
||||||
|
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
|
||||||
} else {
|
} else {
|
||||||
unlink($path);
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-build: runtime pages built — %d core entries + %d module entries.\n",
|
||||||
|
(int) ($result['core_entries'] ?? 0),
|
||||||
|
(int) ($result['module_entries'] ?? 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
|
||||||
|
$exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (isset($lock) && is_resource($lock)) {
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||||
|
fwrite(STDOUT, sprintf("module-build: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||||
|
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
|
exit(moduleBuildRun());
|
||||||
}
|
}
|
||||||
|
|||||||
116
bin/module-cli-bootstrap.php
Normal file
116
bin/module-cli-bootstrap.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared CLI bootstrap and error policy for module runtime scripts.
|
||||||
|
*
|
||||||
|
* Policy:
|
||||||
|
* - first-party warnings/notices/deprecations => fail-fast (RuntimeException)
|
||||||
|
* - vendor warnings/notices/deprecations => ignored and counted
|
||||||
|
*/
|
||||||
|
|
||||||
|
function moduleCliProjectRoot(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliVendorWarningsIgnoredTotal(): int
|
||||||
|
{
|
||||||
|
$count = $GLOBALS['module_cli_vendor_warnings_ignored'] ?? 0;
|
||||||
|
return is_int($count) ? $count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliVendorWarningsIgnoredSince(int $before): int
|
||||||
|
{
|
||||||
|
return max(0, moduleCliVendorWarningsIgnoredTotal() - $before);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliIncrementVendorWarningsIgnored(): void
|
||||||
|
{
|
||||||
|
$current = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
$GLOBALS['module_cli_vendor_warnings_ignored'] = $current + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliSeverityName(int $severity): string
|
||||||
|
{
|
||||||
|
return match ($severity) {
|
||||||
|
E_WARNING => 'E_WARNING',
|
||||||
|
E_NOTICE => 'E_NOTICE',
|
||||||
|
E_USER_WARNING => 'E_USER_WARNING',
|
||||||
|
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||||
|
E_DEPRECATED => 'E_DEPRECATED',
|
||||||
|
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||||
|
default => 'E_' . $severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliInstallErrorPolicy(): void
|
||||||
|
{
|
||||||
|
static $installed = false;
|
||||||
|
if ($installed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$installed = true;
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
|
||||||
|
set_error_handler(static function (
|
||||||
|
int $severity,
|
||||||
|
string $message,
|
||||||
|
string $file = '',
|
||||||
|
int $line = 0
|
||||||
|
): bool {
|
||||||
|
$handledSeverities = [
|
||||||
|
E_WARNING,
|
||||||
|
E_NOTICE,
|
||||||
|
E_USER_WARNING,
|
||||||
|
E_USER_NOTICE,
|
||||||
|
E_DEPRECATED,
|
||||||
|
E_USER_DEPRECATED,
|
||||||
|
];
|
||||||
|
if (!in_array($severity, $handledSeverities, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((error_reporting() & $severity) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedFile = str_replace('\\', '/', $file);
|
||||||
|
if (str_contains($normalizedFile, '/vendor/')) {
|
||||||
|
moduleCliIncrementVendorWarningsIgnored();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
"First-party runtime warning (%s) in %s:%d: %s",
|
||||||
|
moduleCliSeverityName($severity),
|
||||||
|
$file,
|
||||||
|
$line,
|
||||||
|
$message
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleCliBootstrapApp(): void
|
||||||
|
{
|
||||||
|
static $booted = false;
|
||||||
|
if ($booted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleCliInstallErrorPolicy();
|
||||||
|
|
||||||
|
$projectRoot = moduleCliProjectRoot();
|
||||||
|
chdir($projectRoot);
|
||||||
|
|
||||||
|
require_once $projectRoot . '/vendor/autoload.php';
|
||||||
|
require_once $projectRoot . '/config/config.php';
|
||||||
|
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||||
|
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||||
|
setAppContainer($container);
|
||||||
|
|
||||||
|
$booted = true;
|
||||||
|
}
|
||||||
@@ -17,102 +17,120 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
chdir(__DIR__ . '/..');
|
|
||||||
|
|
||||||
require 'vendor/autoload.php';
|
|
||||||
require 'config/config.php';
|
|
||||||
require 'lib/Support/helpers.php';
|
|
||||||
$container = require 'lib/App/registerContainer.php';
|
|
||||||
setAppContainer($container);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\DB;
|
use MintyPHP\DB;
|
||||||
|
|
||||||
$exitCode = 0;
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
$totalApplied = 0;
|
|
||||||
|
|
||||||
try {
|
function moduleMigrateRun(): int
|
||||||
$registry = app(ModuleRegistry::class);
|
{
|
||||||
$modules = $registry->getModules();
|
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
moduleCliBootstrapApp();
|
||||||
|
|
||||||
if (count($modules) === 0) {
|
$exitCode = 0;
|
||||||
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
|
$totalApplied = 0;
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure tracking table exists (safe if already created by db/updates/ script)
|
try {
|
||||||
DB::execute(
|
$registry = app(ModuleRegistry::class);
|
||||||
'CREATE TABLE IF NOT EXISTS `module_migrations` (
|
$modules = $registry->getModules();
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
$hasModules = count($modules) > 0;
|
||||||
`module_id` VARCHAR(64) NOT NULL,
|
|
||||||
`filename` VARCHAR(255) NOT NULL,
|
|
||||||
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($modules as $manifest) {
|
if (!$hasModules) {
|
||||||
$migrationsPath = $manifest->migrationsPath;
|
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
|
||||||
if ($migrationsPath === null || !is_dir($migrationsPath)) {
|
} else {
|
||||||
continue;
|
// Ensure tracking table exists (safe if already created by db/updates/ script)
|
||||||
}
|
DB::query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `module_migrations` (
|
||||||
// Discover SQL files
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
$files = glob($migrationsPath . '/*.sql');
|
`module_id` VARCHAR(64) NOT NULL,
|
||||||
if ($files === false || count($files) === 0) {
|
`filename` VARCHAR(255) NOT NULL,
|
||||||
continue;
|
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
}
|
PRIMARY KEY (`id`),
|
||||||
sort($files); // alphabetical order
|
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||||
// Get already-applied filenames
|
|
||||||
$applied = [];
|
|
||||||
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]);
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$applied[$row['filename']] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$filename = basename($file);
|
|
||||||
if (isset($applied[$filename])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = file_get_contents($file);
|
|
||||||
if ($sql === false || trim($sql) === '') {
|
|
||||||
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
|
|
||||||
|
|
||||||
// Execute migration (may contain multiple statements)
|
|
||||||
$statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== '');
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
DB::execute($statement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record as applied
|
|
||||||
DB::execute(
|
|
||||||
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
|
|
||||||
[$manifest->id, $filename]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fwrite(STDOUT, "OK\n");
|
|
||||||
$totalApplied++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($modules as $manifest) {
|
||||||
|
$migrationsPath = $manifest->migrationsPath;
|
||||||
|
if ($migrationsPath === null || !is_dir($migrationsPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover SQL files
|
||||||
|
$files = glob($migrationsPath . '/*.sql');
|
||||||
|
if ($files === false || count($files) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sort($files); // alphabetical order
|
||||||
|
|
||||||
|
// Get already-applied filenames
|
||||||
|
$applied = [];
|
||||||
|
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$applied[$row['filename']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
if (isset($applied[$filename])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
if ($sql === false || trim($sql) === '') {
|
||||||
|
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
|
||||||
|
|
||||||
|
// Execute migration inside a transaction so partial failures don't leave the DB inconsistent
|
||||||
|
DB::query('START TRANSACTION');
|
||||||
|
try {
|
||||||
|
$statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== '');
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
DB::query($statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record as applied
|
||||||
|
DB::query(
|
||||||
|
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
|
||||||
|
[$manifest->id, $filename]
|
||||||
|
);
|
||||||
|
DB::query('COMMIT');
|
||||||
|
} catch (\Throwable $migrationError) {
|
||||||
|
DB::query('ROLLBACK');
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf("Migration %s/%s failed: %s", $manifest->id, $filename, $migrationError->getMessage()),
|
||||||
|
0,
|
||||||
|
$migrationError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK\n");
|
||||||
|
$totalApplied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasModules) {
|
||||||
|
// Already reported above.
|
||||||
|
} elseif ($totalApplied === 0) {
|
||||||
|
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
|
||||||
|
} else {
|
||||||
|
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage()));
|
||||||
|
$exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($totalApplied === 0) {
|
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||||
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
|
fwrite(STDOUT, sprintf("module-migrate: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||||
} else {
|
|
||||||
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
|
return $exitCode;
|
||||||
}
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage()));
|
|
||||||
$exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
DB::close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exit($exitCode);
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
|
exit(moduleMigrateRun());
|
||||||
|
}
|
||||||
|
|||||||
55
bin/module-permissions-sync.php
Normal file
55
bin/module-permissions-sync.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes module-declared permissions into the permissions table.
|
||||||
|
*
|
||||||
|
* Usage: php bin/module-permissions-sync.php
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — sync successful
|
||||||
|
* 1 — error during sync
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\Repository\Access\PermissionRepository;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
|
|
||||||
|
function modulePermissionsSyncRun(): int
|
||||||
|
{
|
||||||
|
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
moduleCliBootstrapApp();
|
||||||
|
|
||||||
|
$exitCode = 0;
|
||||||
|
try {
|
||||||
|
$synchronizer = new ModulePermissionSynchronizer(
|
||||||
|
app(ModuleRegistry::class),
|
||||||
|
app(PermissionRepository::class)
|
||||||
|
);
|
||||||
|
$result = $synchronizer->sync();
|
||||||
|
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-permissions-sync: created=%d updated=%d unchanged=%d total=%d\n",
|
||||||
|
$result['created'],
|
||||||
|
$result['updated'],
|
||||||
|
$result['unchanged'],
|
||||||
|
$result['total']
|
||||||
|
));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, sprintf("module-permissions-sync: FAILED: %s\n", $exception->getMessage()));
|
||||||
|
$exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||||
|
fwrite(STDOUT, sprintf("module-permissions-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
|
||||||
|
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
|
exit(modulePermissionsSyncRun());
|
||||||
|
}
|
||||||
67
bin/module-runtime-sync.php
Executable file
67
bin/module-runtime-sync.php
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
|
|
||||||
|
require_once __DIR__ . '/module-migrate.php';
|
||||||
|
require_once __DIR__ . '/module-permissions-sync.php';
|
||||||
|
require_once __DIR__ . '/module-build.php';
|
||||||
|
require_once __DIR__ . '/module-assets-sync.php';
|
||||||
|
|
||||||
|
function moduleRuntimeSyncRun(): int
|
||||||
|
{
|
||||||
|
moduleCliBootstrapApp();
|
||||||
|
$warningsBeforeAll = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
|
||||||
|
$steps = [
|
||||||
|
'migrate' => static fn (): int => moduleMigrateRun(),
|
||||||
|
'permissions-sync' => static fn (): int => modulePermissionsSyncRun(),
|
||||||
|
'build' => static fn (): int => moduleBuildRun(),
|
||||||
|
'assets-sync' => static fn (): int => moduleAssetsSyncRun(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$statusByStep = [];
|
||||||
|
foreach (array_keys($steps) as $stepName) {
|
||||||
|
$statusByStep[$stepName] = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalExitCode = 0;
|
||||||
|
foreach ($steps as $stepName => $runner) {
|
||||||
|
$stepWarningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||||
|
$stepExitCode = $runner();
|
||||||
|
$stepWarnings = moduleCliVendorWarningsIgnoredSince($stepWarningsBefore);
|
||||||
|
$stepStatus = $stepExitCode === 0 ? 'ok' : 'failed';
|
||||||
|
$statusByStep[$stepName] = $stepStatus;
|
||||||
|
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
||||||
|
$stepName,
|
||||||
|
$stepStatus,
|
||||||
|
$stepExitCode,
|
||||||
|
$stepWarnings
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($stepExitCode !== 0) {
|
||||||
|
$finalExitCode = $stepExitCode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalVendorWarnings = moduleCliVendorWarningsIgnoredSince($warningsBeforeAll);
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d\n",
|
||||||
|
$statusByStep['migrate'],
|
||||||
|
$statusByStep['permissions-sync'],
|
||||||
|
$statusByStep['build'],
|
||||||
|
$statusByStep['assets-sync'],
|
||||||
|
$totalVendorWarnings
|
||||||
|
));
|
||||||
|
|
||||||
|
return $finalExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
|
exit(moduleRuntimeSyncRun());
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'shared' => [
|
'shared' => [
|
||||||
'css/components/app-badges.css',
|
'css/components/app-badges.css',
|
||||||
'css/layout/app-shell.css',
|
'css/layout/app-shell.css',
|
||||||
|
'css/components/app-dialog.css',
|
||||||
'css/components/app-blockquote.css',
|
'css/components/app-blockquote.css',
|
||||||
'css/components/app-forms.css',
|
'css/components/app-forms.css',
|
||||||
'css/components/app-flash.css',
|
'css/components/app-flash.css',
|
||||||
@@ -46,14 +47,10 @@ return [
|
|||||||
'css/vendor-overrides/editorjs.css',
|
'css/vendor-overrides/editorjs.css',
|
||||||
'css/components/app-page-copy.css',
|
'css/components/app-page-copy.css',
|
||||||
'css/layout/app-aside-icon-bar.css',
|
'css/layout/app-aside-icon-bar.css',
|
||||||
'css/components/app-bookmark-form.css',
|
|
||||||
],
|
],
|
||||||
'login' => [
|
'login' => [
|
||||||
'css/pages/app-login.css',
|
'css/pages/app-login.css',
|
||||||
],
|
],
|
||||||
'address-book' => [
|
|
||||||
'css/pages/address-book-view.css',
|
|
||||||
],
|
|
||||||
'error' => [
|
'error' => [
|
||||||
'css/pages/app-error.css',
|
'css/pages/app-error.css',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
* Module activation configuration.
|
* Module activation configuration.
|
||||||
*
|
*
|
||||||
* Modules listed here are loaded at boot time. The APP_ENABLED_MODULES env
|
* Modules listed here are loaded at boot time. The APP_ENABLED_MODULES env
|
||||||
* variable (comma-separated) overrides this list when set.
|
* variable controls overrides with deterministic semantics:
|
||||||
|
* - APP_ENABLED_MODULES not set: use this config list
|
||||||
|
* - APP_ENABLED_MODULES='': explicit no-modules mode
|
||||||
|
* - APP_ENABLED_MODULES='a,b': exact module list from env
|
||||||
*
|
*
|
||||||
* Each entry must match a directory name under modules/ containing a module.php manifest.
|
* Each entry must match a directory name under modules/ containing a module.php manifest.
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
'enabled_modules' => ['addressbook'],
|
'enabled_modules' => ['addressbook', 'bookmarks'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,26 +9,41 @@ if (!is_array($routeDefinitions) || !$routeDefinitions) {
|
|||||||
$routeDefinitions = is_file($routesFile) ? include $routesFile : [];
|
$routeDefinitions = is_file($routesFile) ? include $routesFile : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$coreRoutePaths = [];
|
||||||
if (is_array($routeDefinitions)) {
|
if (is_array($routeDefinitions)) {
|
||||||
foreach ($routeDefinitions as $route) {
|
foreach ($routeDefinitions as $route) {
|
||||||
$path = trim((string) ($route['path'] ?? ''));
|
$path = trim((string) ($route['path'] ?? ''));
|
||||||
$target = trim((string) ($route['target'] ?? ''));
|
$target = trim((string) ($route['target'] ?? ''));
|
||||||
if ($path !== '' && $target !== '') {
|
if ($path === '' || $target === '') {
|
||||||
Router::addRoute($path, $target);
|
continue;
|
||||||
}
|
}
|
||||||
|
$coreRoutePaths[$path] = $target;
|
||||||
|
Router::addRoute($path, $target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Module routes ────────────────────────────────────────────────────
|
// ── Module routes ────────────────────────────────────────────────────
|
||||||
// Module routes are merged after core routes. The ModuleRegistry is
|
// Module routes are merged after core routes. web/index.php includes this file
|
||||||
// available at this point because registerContainer.php runs before
|
// only after container bootstrap, so ModuleRegistry is available here.
|
||||||
// config/router.php is included.
|
if (app(ModuleRegistry::class) instanceof ModuleRegistry) {
|
||||||
if (function_exists('app') && app(ModuleRegistry::class) instanceof ModuleRegistry) {
|
|
||||||
$moduleRegistry = app(ModuleRegistry::class);
|
$moduleRegistry = app(ModuleRegistry::class);
|
||||||
|
$modulePaths = [];
|
||||||
foreach ($moduleRegistry->getRoutes() as $route) {
|
foreach ($moduleRegistry->getRoutes() as $route) {
|
||||||
$path = trim((string) $route['path']);
|
$path = trim((string) $route['path']);
|
||||||
$target = trim((string) $route['target']);
|
$target = trim((string) $route['target']);
|
||||||
|
$moduleId = trim((string) $route['module_id']);
|
||||||
if ($path !== '' && $target !== '') {
|
if ($path !== '' && $target !== '') {
|
||||||
|
if (isset($coreRoutePaths[$path])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with core route target '{$coreRoutePaths[$path]}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isset($modulePaths[$path])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with module '{$modulePaths[$path]}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$modulePaths[$path] = $moduleId;
|
||||||
Router::addRoute($path, $target);
|
Router::addRoute($path, $target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dokumentation
|
# Dokumentation
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-09
|
Letzte Aktualisierung: 2026-03-18
|
||||||
|
|
||||||
Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: Tutorials zum Lernen, How-to Guides zum Nachschlagen von Aufgaben, Explanation zum Verstehen und Reference zum Nachschlagen von Fakten.
|
Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: Tutorials zum Lernen, How-to Guides zum Nachschlagen von Aufgaben, Explanation zum Verstehen und Reference zum Nachschlagen von Fakten.
|
||||||
|
|
||||||
@@ -96,7 +96,9 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert:
|
|||||||
- `/docs/reference-codex-prompts.md`
|
- `/docs/reference-codex-prompts.md`
|
||||||
- Standard-Prompts fuer Skill-basierte Planung und Implementierung.
|
- Standard-Prompts fuer Skill-basierte Planung und Implementierung.
|
||||||
- `/docs/reference-extension-readiness.md`
|
- `/docs/reference-extension-readiness.md`
|
||||||
- Verbindliche Vorbedingungen fuer spaetere Module ohne Hook-/Lifecycle-Festlegung.
|
- Freigabe-Checkliste fuer weitere Module auf Basis des bestehenden Modul-Contracts.
|
||||||
|
- `/docs/reference-module-manifest-contract.md`
|
||||||
|
- Verbindlicher Runtime- und Manifest-Contract fuer Module (V1.1).
|
||||||
- `/docs/reference-agents-overview.md`
|
- `/docs/reference-agents-overview.md`
|
||||||
- Einstieg in das Rollenmodell und die Artefakte.
|
- Einstieg in das Rollenmodell und die Artefakte.
|
||||||
- `/docs/reference-agents-roles.md`
|
- `/docs/reference-agents-roles.md`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Entwickler-Checkliste
|
# Entwickler-Checkliste
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-09
|
Letzte Aktualisierung: 2026-03-18
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -41,6 +41,12 @@ Kurze Definition of Done vor Merge.
|
|||||||
- [ ] Keine inline `<script type="module">`-Blöcke in `pages/**`/`templates/**`; Seitenlogik liegt in `web/js/pages/*`
|
- [ ] Keine inline `<script type="module">`-Blöcke in `pages/**`/`templates/**`; Seitenlogik liegt in `web/js/pages/*`
|
||||||
- [ ] Für Seitenmodule ist Config über `<script type="application/json" id="page-config-...">` bereitgestellt
|
- [ ] Für Seitenmodule ist Config über `<script type="application/json" id="page-config-...">` bereitgestellt
|
||||||
- [ ] JS-`src` in `pages/**`/`templates/**` nur über `assetVersion('...js...')`
|
- [ ] JS-`src` in `pages/**`/`templates/**` nur über `assetVersion('...js...')`
|
||||||
|
- [ ] Runtime-Komponenten folgen Contract `init(root, config) -> { destroy() }`
|
||||||
|
- [ ] Runtime-Komponenten enthalten keinen eigenen `DOMContentLoaded`-/Sideeffect-Auto-Init
|
||||||
|
- [ ] Listener/Timer/Observer aus Runtime-Komponenten werden in `destroy()` sauber abgebaut
|
||||||
|
- [ ] Runtime-Konfiguration liegt in `page-config-app-components` (default) bzw. `page-config-login-components` (login) und wird ueber `readPageConfig(...)` gelesen
|
||||||
|
- [ ] Host-gebundene Runtime-Komponenten haben explizite `data-app-component`-Hosts im Markup
|
||||||
|
- [ ] Global-Utilities ohne natuerliches Host-Element sind explizit runtime-global registriert (`scope: 'global'`)
|
||||||
- [ ] Service-Auflösung in `pages/**`/`templates/**`/`lib/Support/**` nur via `app(Foo::class)`
|
- [ ] Service-Auflösung in `pages/**`/`templates/**`/`lib/Support/**` nur via `app(Foo::class)`
|
||||||
- [ ] Keine `new ...Factory()` in `lib/Service/**` außerhalb `*Factory.php`
|
- [ ] Keine `new ...Factory()` in `lib/Service/**` außerhalb `*Factory.php`
|
||||||
- [ ] Keine direkten `new ...Service()`, `new ...Gateway()`, `new ...Repository()` in `lib/Service/**` außerhalb `*Factory.php`
|
- [ ] Keine direkten `new ...Service()`, `new ...Gateway()`, `new ...Repository()` in `lib/Service/**` außerhalb `*Factory.php`
|
||||||
@@ -62,9 +68,13 @@ Kurze Definition of Done vor Merge.
|
|||||||
|
|
||||||
## Qualitätschecks
|
## Qualitätschecks
|
||||||
|
|
||||||
- [ ] `docker compose exec php vendor/bin/phpunit`
|
- [ ] `docker compose exec php vendor/bin/phpunit` zweimal hintereinander in identischer Umgebung (beide Runs gruen)
|
||||||
- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
|
- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
|
||||||
- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
|
- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
|
||||||
|
- [ ] bei Modul-/Manifest-/Routen-Aenderungen: `APP_ENABLED_MODULES='' php bin/module-runtime-sync.php`
|
||||||
|
- [ ] bei Modul-/Manifest-/Routen-Aenderungen: `APP_ENABLED_MODULES='addressbook,bookmarks' php bin/module-runtime-sync.php`
|
||||||
|
- [ ] Runtime-Sync erzeugt Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) und `vendor_warnings_ignored=<n>`
|
||||||
|
- [ ] Keine First-party CLI-Warnings/Notices/Deprecations im Runtime-Sync (Vendor-Warnings sind nur temporaer toleriert und muessen gezaehlt sichtbar sein)
|
||||||
- [ ] bei Docs-/Skills-Aenderungen: `docker compose exec php vendor/bin/phpunit tests/Architecture/CodexSkillsContractTest.php`
|
- [ ] bei Docs-/Skills-Aenderungen: `docker compose exec php vendor/bin/phpunit tests/Architecture/CodexSkillsContractTest.php`
|
||||||
- [ ] bei Docs-/Skills-Aenderungen: `bin/docs-link-check.sh` (scannt standardmaessig ohne `agent-system/runs/**`)
|
- [ ] bei Docs-/Skills-Aenderungen: `bin/docs-link-check.sh` (scannt standardmaessig ohne `agent-system/runs/**`)
|
||||||
- [ ] bei Docs-/Skills-Aenderungen: `bin/codex-skills-sync.sh --check`
|
- [ ] bei Docs-/Skills-Aenderungen: `bin/codex-skills-sync.sh --check`
|
||||||
@@ -82,6 +92,7 @@ Kurze Definition of Done vor Merge.
|
|||||||
- [ ] betroffene Edit-Seite + Save
|
- [ ] betroffene Edit-Seite + Save
|
||||||
- [ ] relevante Tenant-Sicht
|
- [ ] relevante Tenant-Sicht
|
||||||
- [ ] ggf. Global Search / API-Flow
|
- [ ] ggf. Global Search / API-Flow
|
||||||
|
- [ ] bei optionalen Modulen: Smoke-Test `APP_ENABLED_MODULES` on/off (keine toten Links, keine Notices/Warnungen)
|
||||||
|
|
||||||
## Abschluss
|
## Abschluss
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,57 @@
|
|||||||
# Extension-Readiness (ohne Architektur-Festlegung)
|
# Extension-Readiness (Module-System V1.1)
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-09
|
Letzte Aktualisierung: 2026-03-18
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Verbindliche Vorbedingungen fuer spaetere eigenstaendige Module festhalten, ohne jetzt eine konkrete Extension-Mechanik (Hooks/Lifecycle/Loader) zu standardisieren.
|
Verbindliche Vorbedingungen fuer weitere eigenstaendige Module auf dem bestehenden Modul-System festhalten.
|
||||||
|
|
||||||
## Readiness-Checkliste vor neuem Modul
|
## Kanonische Quelle
|
||||||
|
|
||||||
|
Normativer Manifest- und Runtime-Contract: `/docs/reference-module-manifest-contract.md`.
|
||||||
|
|
||||||
|
Diese Readiness-Doku ist bewusst kurz und verweist fuer Felddefinitionen/Guards immer auf den Manifest-Contract.
|
||||||
|
|
||||||
|
## Readiness-Checkliste vor Modul 2+
|
||||||
|
|
||||||
1. **Modulgrenzen klarziehen**
|
1. **Modulgrenzen klarziehen**
|
||||||
- Betroffene Schichten benoennen (`pages -> Service -> Repository`).
|
- Betroffene Schichten benoennen (`pages -> Service -> Repository`).
|
||||||
- Keine SQL-/Business-Logik in Templates oder `pages/*.phtml`.
|
- Keine Modul-spezifischen Hardcodings im Core-Layout/Core-Routing.
|
||||||
2. **Contracts definieren**
|
2. **Manifest-Contract einhalten**
|
||||||
- Oeffentliche API-/UI-Contracts dokumentieren (Inputs, Outputs, Fehlerfaelle).
|
- `module.php` validiert gegen den kanonischen Contract (`permissions`, `scheduler_jobs`, `ui_slots`, `routes`).
|
||||||
- Bei API-Aenderungen `docs/openapi.yaml` im selben Merge pflegen.
|
- Route-/Slot-/Layout-Guards laufen fail-fast.
|
||||||
3. **Security + Scope absichern**
|
3. **Security + Scope absichern**
|
||||||
- AuthZ/RBAC serverseitig pruefen.
|
- AuthZ/RBAC serverseitig pruefen.
|
||||||
- Tenant-Scope serverseitig pruefen.
|
- Tenant-Scope serverseitig pruefen.
|
||||||
- POST-Mutationen mit CSRF absichern.
|
- POST-Mutationen mit CSRF absichern.
|
||||||
4. **Testbarkeit und Gates**
|
4. **Runtime-Hygiene absichern**
|
||||||
|
- `php bin/module-runtime-sync.php` laeuft reproduzierbar mit standardisierter Step-Summary.
|
||||||
|
- Keine First-party CLI-Warnings/Notices/Deprecations; Vendor-Rauschen wird nur gezaehlt/reportet.
|
||||||
|
5. **Testbarkeit und Gates**
|
||||||
- Architektur- und Domain-Tests fuer neue Logik ergaenzen.
|
- Architektur- und Domain-Tests fuer neue Logik ergaenzen.
|
||||||
- Relevante Quality Gates (`QG-*`) im Merge reporten.
|
- `phpunit` und `phpstan` als Pflicht-Gates gruen.
|
||||||
5. **Daten- und Betriebsfaehigkeit**
|
6. **Daten- und Betriebsfaehigkeit**
|
||||||
- Schema-/Datenaenderungen fuer Bestandsinstallationen idempotent in `db/updates/*.sql`.
|
- Schema-/Datenaenderungen fuer Bestandsinstallationen idempotent in `db/updates/*.sql`.
|
||||||
- Logs/Audit ohne unredacted PII/Secrets.
|
- Logs/Audit ohne unredacted PII/Secrets.
|
||||||
|
|
||||||
## Bewusst Out of Scope in diesem Stand
|
## Derzeit Out of Scope
|
||||||
|
|
||||||
- Kein standardisierter Extension-Loader.
|
- Eigener DB-Server oder Netzwerk-Microservice je Modul.
|
||||||
- Kein Hook-/Plugin-Lifecycle.
|
- Externe Drittanbieter-Module mit Legacy-Kompatibilitaet.
|
||||||
- Keine verbindlichen Modul-Ablageorte ausserhalb bestehender Schichten.
|
|
||||||
|
|
||||||
## Entscheidungslog fuer spaetere Standardisierung
|
## Verfuegbare Core-Services fuer Module
|
||||||
|
|
||||||
Wenn die Extension-Mechanik spaeter konkretisiert wird, muss die Entscheidung mindestens diese Punkte mitliefern:
|
JS-seitig:
|
||||||
|
|
||||||
1. Aktivierung/Deaktivierung und Reihenfolge von Modulen.
|
- Confirm-Dialog (`confirmDialog`)
|
||||||
2. Versionierung und Migrationsstrategie pro Modul.
|
- Async-Flash (`showAsyncFlash`)
|
||||||
3. Sicherheits- und Tenant-Isolation pro Erweiterung.
|
- DOM-Utilities (`app-dom.js`)
|
||||||
4. Test- und Rollback-Strategie fuer Modul-Deployments.
|
- CSRF-Metadaten über `data-csrf-key` / `data-csrf-token`
|
||||||
|
|
||||||
|
PHP-seitig:
|
||||||
|
|
||||||
|
- Guard (`MintyPHP\\Support\\Guard`)
|
||||||
|
- SessionStore (`MintyPHP\\Http\\SessionStoreInterface`)
|
||||||
|
- Flash (`MintyPHP\\Support\\Flash`)
|
||||||
|
- DI-Container (`MintyPHP\\App\\AppContainer`)
|
||||||
|
- Modul-Contracts (`LayoutContextProvider`, `SessionProvider`, `SearchResourceProvider`)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Frontend JavaScript
|
# Frontend JavaScript
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-06
|
Letzte Aktualisierung: 2026-03-18
|
||||||
|
|
||||||
## Ordnerstruktur
|
## Ordnerstruktur
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ Letzte Aktualisierung: 2026-03-06
|
|||||||
- setzt früh UI-States (no-js/js, Sidebar-/Contrast-LocalStorage)
|
- setzt früh UI-States (no-js/js, Sidebar-/Contrast-LocalStorage)
|
||||||
- `/web/js/app-init.js`
|
- `/web/js/app-init.js`
|
||||||
- Modul-Entrypoint für Standardseiten
|
- Modul-Entrypoint für Standardseiten
|
||||||
- importiert Komponenten zentral
|
- initialisiert Runtime + globale Komponenten zentral
|
||||||
- `/web/js/app-login-init.js`
|
- `/web/js/app-login-init.js`
|
||||||
- reduzierter Entrypoint für Loginseiten
|
- reduzierter Entrypoint für Loginseiten
|
||||||
|
|
||||||
@@ -30,6 +30,34 @@ Empfohlenes Muster:
|
|||||||
2. Früher Guard auf fehlende Elemente
|
2. Früher Guard auf fehlende Elemente
|
||||||
3. Keine stillen Fehler verschlucken
|
3. Keine stillen Fehler verschlucken
|
||||||
4. Nur DOM/UI-Verhalten, keine Fachlogik
|
4. Nur DOM/UI-Verhalten, keine Fachlogik
|
||||||
|
5. Lifecycle-Contract für Runtime-Komponenten: `init(root, config)` gibt API mit `destroy()` zurück
|
||||||
|
6. Runtime-Komponenten dürfen keinen eigenen Auto-Init via `DOMContentLoaded`/Sideeffect enthalten
|
||||||
|
|
||||||
|
## Component Runtime (Restmigration)
|
||||||
|
|
||||||
|
- Zentrale Runtime: `web/js/core/app-component-runtime.js`.
|
||||||
|
- Runtime-Page-Configs:
|
||||||
|
- Standardseiten: `<script type="application/json" id="page-config-app-components">...</script>`
|
||||||
|
- Loginseiten: `<script type="application/json" id="page-config-login-components">...</script>`
|
||||||
|
- Runtime liest Config über `readPageConfig(...)` und mountet registrierte Komponenten pro Entrypoint:
|
||||||
|
- `web/js/app-init.js` (default)
|
||||||
|
- `web/js/app-login-init.js` (login)
|
||||||
|
- Komponenteninterner Contract:
|
||||||
|
- Init-Signatur: `initX(root, config)`
|
||||||
|
- Rueckgabe: `{ destroy() }`
|
||||||
|
- `destroy()` muss alle Listener/Timer/Observer abbauen.
|
||||||
|
- Hybrid Host-Policy:
|
||||||
|
- Host-gebundene UI-Komponenten nutzen explizite `data-app-component`-Hosts im Markup.
|
||||||
|
- Tabs sind host-gebunden und laufen ausschliesslich ueber `data-app-component="tabs"`.
|
||||||
|
- Globale Utilities ohne natuerliches Host-Element werden runtime-global (`scope: 'global'`) registriert, aber mit demselben Lifecycle-Contract.
|
||||||
|
- Root-Strict gilt fuer host-gebundene Komponenten: keine `document.querySelector`-Fallback-Pfade.
|
||||||
|
- Projektseitige Custom-Globals auf `window.*` sind verboten; Komponenten kommunizieren ueber Core-Channels.
|
||||||
|
|
||||||
|
## UI Storage Contract
|
||||||
|
|
||||||
|
- Persistente UI-States laufen fuer Runtime-Komponenten ueber `web/js/core/app-ui-storage.js`.
|
||||||
|
- Storage-Keys sind namespaced (`namespace:version:scope:*`) statt ungeordnet verteilt.
|
||||||
|
- Hard-Cut-Policy: keine Legacy-Key-Fallbacks und kein Legacy-Key-Sync in migrierten Runtime-Komponenten/Core-Modulen.
|
||||||
|
|
||||||
Template für neue Komponenten:
|
Template für neue Komponenten:
|
||||||
|
|
||||||
@@ -257,7 +285,7 @@ Für Create/Edit-Detailseiten gilt:
|
|||||||
|
|
||||||
- Hauptformular über `data-standard-detail-form="1"` explizit opt-in markieren.
|
- Hauptformular über `data-standard-detail-form="1"` explizit opt-in markieren.
|
||||||
- Initialisierung läuft zentral über `initStandardDetailPage(...)` aus `web/js/core/app-detail-page-factory.js`.
|
- Initialisierung läuft zentral über `initStandardDetailPage(...)` aus `web/js/core/app-detail-page-factory.js`.
|
||||||
- Auto-Init erfolgt über `web/js/components/app-standard-detail-page.js` (in `app-init.js` eingebunden).
|
- Runtime-Init erfolgt über `web/js/app-init.js` (Komponente `standard-detail-page`).
|
||||||
|
|
||||||
Standardverhalten:
|
Standardverhalten:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Konventionen (kurz und verbindlich)
|
# Konventionen (kurz und verbindlich)
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-09
|
Letzte Aktualisierung: 2026-03-17
|
||||||
|
|
||||||
## 1) Code
|
## 1) Code
|
||||||
|
|
||||||
@@ -45,6 +45,8 @@ Letzte Aktualisierung: 2026-03-09
|
|||||||
- Templates nur für Rendering.
|
- Templates nur für Rendering.
|
||||||
- UI-Texte über `t('...')`.
|
- UI-Texte über `t('...')`.
|
||||||
- Strukturregeln in `/docs/reference-frontend-css.md` und `/docs/reference-frontend-javascript.md`.
|
- Strukturregeln in `/docs/reference-frontend-css.md` und `/docs/reference-frontend-javascript.md`.
|
||||||
|
- Runtime-Komponenten nur über Lifecycle-Contract (`init(root, config)` + `destroy()`), ohne komponenteneigenen Auto-Init.
|
||||||
|
- Host-gebundene Runtime-Komponenten über `data-app-component` markieren; nur echte Global-Utilities ohne Host als runtime-global registrieren.
|
||||||
|
|
||||||
## 6) Mindestchecks vor Merge
|
## 6) Mindestchecks vor Merge
|
||||||
|
|
||||||
|
|||||||
99
docs/reference-module-manifest-contract.md
Normal file
99
docs/reference-module-manifest-contract.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Module Manifest Contract (V1.1)
|
||||||
|
|
||||||
|
Letzte Aktualisierung: 2026-03-18
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Verbindlicher Contract fuer `modules/*/module.php`, damit Module zur Runtime deterministisch geladen und validiert werden.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Dieses Dokument ist die kanonische (normative) Quelle fuer Manifest-, Runtime- und Guard-Regeln des Modul-Systems.
|
||||||
|
`/docs/reference-extension-readiness.md` referenziert diesen Contract und dupliziert keine Felddefinitionen.
|
||||||
|
|
||||||
|
## Aktivierung (`APP_ENABLED_MODULES`)
|
||||||
|
|
||||||
|
- nicht gesetzt: `config/modules.php` gilt.
|
||||||
|
- gesetzt und leer (`APP_ENABLED_MODULES=''`): keine Module.
|
||||||
|
- gesetzt mit Liste (`APP_ENABLED_MODULES='a,b'`): exakt diese Module.
|
||||||
|
|
||||||
|
## `permissions` Contract
|
||||||
|
|
||||||
|
`permissions` ist eine Objektliste:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'permissions' => [
|
||||||
|
[
|
||||||
|
'key' => 'module.permission',
|
||||||
|
'description' => 'Human readable text',
|
||||||
|
'active' => true, // optional, default true
|
||||||
|
'is_system' => true, // optional, default true
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- `key` und `description` sind Pflichtfelder.
|
||||||
|
- Runtime-Sync erfolgt ueber `php bin/module-permissions-sync.php`.
|
||||||
|
- `module-runtime-sync` fuehrt diesen Sync automatisch aus.
|
||||||
|
|
||||||
|
## `scheduler_jobs` Contract
|
||||||
|
|
||||||
|
`scheduler_jobs` ist eine Objektliste mit vollem Default-Contract:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'scheduler_jobs' => [
|
||||||
|
[
|
||||||
|
'job_key' => 'module.job',
|
||||||
|
'handler' => App\\Scheduler\\MyJobHandler::class,
|
||||||
|
'label' => 'Job label',
|
||||||
|
'description' => 'Job description',
|
||||||
|
'default_enabled' => true,
|
||||||
|
'default_timezone' => 'Europe/Berlin',
|
||||||
|
'default_schedule_type' => 'daily', // hourly|daily|weekly
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '02:00', // required for daily/weekly
|
||||||
|
'default_schedule_weekdays_csv' => null, // weekly: e.g. '1,3,5'
|
||||||
|
'default_catchup_once' => true,
|
||||||
|
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- Manifest ist Source of Truth fuer Job-Metadaten und Defaults.
|
||||||
|
- Handler werden zur Laufzeit container-resolvable fuer `execute(...)` geladen.
|
||||||
|
|
||||||
|
## `ui_slots` Contract (V1.2)
|
||||||
|
|
||||||
|
Neue generische Slot-Typen:
|
||||||
|
|
||||||
|
- `topbar.right_item`: `{ key, template, permission?, order }`
|
||||||
|
- `layout.body_end_template`: `{ key, template, permission?, order }`
|
||||||
|
- `layout.head_style`: `{ key, path, permission?, order }`
|
||||||
|
- `runtime.component`: `{ key, script, export?, selector?, scope?, config_path?, default_config?, phase?, permission?, order }`
|
||||||
|
|
||||||
|
`runtime.component.phase` ist `early|default|late` (Default: `late`).
|
||||||
|
|
||||||
|
Abgrenzung CSS:
|
||||||
|
|
||||||
|
- `layout.head_style` = global auf allen Seiten.
|
||||||
|
- `asset_groups` = seiten-/kontextbezogen ueber `style_groups`.
|
||||||
|
|
||||||
|
## Guard-Regeln
|
||||||
|
|
||||||
|
- Route-Kollisionen sind fail-fast:
|
||||||
|
- Modul vs Modul auf `target` und `path`.
|
||||||
|
- Modul vs Core auf `path` beim Router-Boot.
|
||||||
|
- Layout-Provider duerfen reservierte Core-Keys nicht ueberschreiben (`moduleSlots`, `csrfKey`, `currentTenant`, ...).
|
||||||
|
- Layout-Provider-Top-Level-Keys muessen namespaced sein (`<module_id>.<key>`).
|
||||||
|
|
||||||
|
## Standard-Runbook
|
||||||
|
|
||||||
|
`php bin/module-runtime-sync.php` fuehrt in fester Reihenfolge aus:
|
||||||
|
|
||||||
|
1. `module-migrate`
|
||||||
|
2. `module-permissions-sync`
|
||||||
|
3. `module-build`
|
||||||
|
4. `module-assets-sync`
|
||||||
|
|
||||||
|
Der Output enthaelt eine deterministische Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) inklusive `vendor_warnings_ignored=<n>`.
|
||||||
|
First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast).
|
||||||
@@ -14,8 +14,13 @@ final class AppContainer
|
|||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
private array $instances = [];
|
private array $instances = [];
|
||||||
|
|
||||||
|
private bool $protectExistingBindings = false;
|
||||||
|
|
||||||
public function set(string $id, callable $factory): void
|
public function set(string $id, callable $factory): void
|
||||||
{
|
{
|
||||||
|
if ($this->protectExistingBindings && $this->has($id)) {
|
||||||
|
throw new RuntimeException('Refusing to overwrite existing service binding: ' . $id);
|
||||||
|
}
|
||||||
$this->bindings[$id] = $factory;
|
$this->bindings[$id] = $factory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,4 +43,12 @@ final class AppContainer
|
|||||||
$this->instances[$id] = ($this->bindings[$id])($this);
|
$this->instances[$id] = ($this->bindings[$id])($this);
|
||||||
return $this->instances[$id];
|
return $this->instances[$id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freeze existing bindings/instances: any later overwrite attempt throws.
|
||||||
|
*/
|
||||||
|
public function protectExistingBindings(): void
|
||||||
|
{
|
||||||
|
$this->protectExistingBindings = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ use MintyPHP\Repository\Stats\AdminStatsRepository;
|
|||||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||||
use MintyPHP\Repository\User\UserReadRepository;
|
use MintyPHP\Repository\User\UserReadRepository;
|
||||||
use MintyPHP\Service\Access\PermissionService;
|
use MintyPHP\Service\Access\PermissionService;
|
||||||
use MintyPHP\Service\AddressBook\AddressBookService;
|
|
||||||
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
|
|
||||||
use MintyPHP\Service\Audit\ApiAuditService;
|
use MintyPHP\Service\Audit\ApiAuditService;
|
||||||
use MintyPHP\Service\Audit\AuditMetadataEnricher;
|
use MintyPHP\Service\Audit\AuditMetadataEnricher;
|
||||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||||
@@ -76,7 +74,6 @@ final class AppServicesRegistrar implements ContainerRegistrar
|
|||||||
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => $c->get(ImportServicesFactory::class)->createImportAuditService());
|
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => $c->get(ImportServicesFactory::class)->createImportAuditService());
|
||||||
$container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService());
|
$container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService());
|
||||||
$container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService());
|
$container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService());
|
||||||
$container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c->get(AddressBookServicesFactory::class)->createAddressBookService());
|
|
||||||
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
|
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
|
||||||
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
|
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
|
||||||
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(
|
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use MintyPHP\Service\Access\AccessGatewayFactory;
|
|||||||
use MintyPHP\Service\Access\AccessPolicyFactory;
|
use MintyPHP\Service\Access\AccessPolicyFactory;
|
||||||
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
||||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||||
|
use MintyPHP\Service\Access\AssignableRoleService;
|
||||||
use MintyPHP\Service\Access\PermissionService;
|
use MintyPHP\Service\Access\PermissionService;
|
||||||
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
|
|
||||||
use MintyPHP\Service\Audit\AuditRepositoryFactory;
|
use MintyPHP\Service\Audit\AuditRepositoryFactory;
|
||||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||||
use MintyPHP\Service\Auth\AuthGatewayFactory;
|
use MintyPHP\Service\Auth\AuthGatewayFactory;
|
||||||
@@ -38,8 +38,6 @@ use MintyPHP\Service\Settings\SettingServicesFactory;
|
|||||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||||
use MintyPHP\Service\Access\AssignableRoleService;
|
|
||||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
|
||||||
use MintyPHP\Service\User\UserGatewayFactory;
|
use MintyPHP\Service\User\UserGatewayFactory;
|
||||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||||
use MintyPHP\Service\User\UserServicesFactory;
|
use MintyPHP\Service\User\UserServicesFactory;
|
||||||
@@ -88,13 +86,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
|||||||
$c->get(UserServicesFactory::class),
|
$c->get(UserServicesFactory::class),
|
||||||
$c->get(AuditServicesFactory::class),
|
$c->get(AuditServicesFactory::class),
|
||||||
$c->get(DatabaseSessionRepository::class),
|
$c->get(DatabaseSessionRepository::class),
|
||||||
$c->get(SchedulerRepositoryFactory::class)
|
$c->get(SchedulerRepositoryFactory::class),
|
||||||
));
|
$c
|
||||||
$container->set(AddressBookServicesFactory::class, static fn (AppContainer $c): AddressBookServicesFactory => new AddressBookServicesFactory(
|
|
||||||
$c->get(UserServicesFactory::class),
|
|
||||||
$c->get(DirectoryServicesFactory::class),
|
|
||||||
$c->get(CustomFieldServicesFactory::class),
|
|
||||||
$c->get(TenantScopeService::class)
|
|
||||||
));
|
));
|
||||||
$container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory(
|
$container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory(
|
||||||
$c->get(UserServicesFactory::class),
|
$c->get(UserServicesFactory::class),
|
||||||
@@ -143,7 +136,6 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
|||||||
$c->get(SettingServicesFactory::class),
|
$c->get(SettingServicesFactory::class),
|
||||||
$c->get(TenantScopeService::class)
|
$c->get(TenantScopeService::class)
|
||||||
));
|
));
|
||||||
$container->set(BookmarkServicesFactory::class, static fn (): BookmarkServicesFactory => new BookmarkServicesFactory());
|
|
||||||
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
|
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
|
||||||
$c->get(UserServicesFactory::class),
|
$c->get(UserServicesFactory::class),
|
||||||
$c->get(AuditServicesFactory::class),
|
$c->get(AuditServicesFactory::class),
|
||||||
@@ -154,7 +146,7 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
|||||||
$c->get(SessionStoreInterface::class),
|
$c->get(SessionStoreInterface::class),
|
||||||
$c->get(CookieStoreInterface::class),
|
$c->get(CookieStoreInterface::class),
|
||||||
$c->get(RequestRuntimeInterface::class),
|
$c->get(RequestRuntimeInterface::class),
|
||||||
$c->get(BookmarkServicesFactory::class)
|
$c
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ use MintyPHP\Service\User\UserLifecycleService;
|
|||||||
use MintyPHP\Service\User\UserPasswordPolicyService;
|
use MintyPHP\Service\User\UserPasswordPolicyService;
|
||||||
use MintyPHP\Service\User\UserPasswordService;
|
use MintyPHP\Service\User\UserPasswordService;
|
||||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
|
||||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
|
||||||
use MintyPHP\Service\User\UserServicesFactory;
|
use MintyPHP\Service\User\UserServicesFactory;
|
||||||
use MintyPHP\Service\User\UserTenantContextService;
|
use MintyPHP\Service\User\UserTenantContextService;
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ final class UserRegistrar implements ContainerRegistrar
|
|||||||
$c->get(UserAccessTemplateService::class),
|
$c->get(UserAccessTemplateService::class),
|
||||||
$c->get(BrandingLogoService::class)
|
$c->get(BrandingLogoService::class)
|
||||||
));
|
));
|
||||||
$container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService());
|
|
||||||
$container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService());
|
$container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService());
|
||||||
$container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService());
|
$container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService());
|
||||||
$container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService());
|
$container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService());
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ use MintyPHP\App\AppContainer;
|
|||||||
*
|
*
|
||||||
* Called from appBuildLayoutNavContext() after Core data assembly.
|
* Called from appBuildLayoutNavContext() after Core data assembly.
|
||||||
* Return value is merged into the $layoutNav array passed to templates.
|
* Return value is merged into the $layoutNav array passed to templates.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Only called in web request context. CLI/scheduler callers
|
||||||
|
* must not invoke layout context providers. Implementations should use
|
||||||
|
* try/catch around request-dependent calls (e.g. requestInput()) as a
|
||||||
|
* defensive measure.
|
||||||
*/
|
*/
|
||||||
interface LayoutContextProvider
|
interface LayoutContextProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ interface SearchResourceProvider
|
|||||||
/**
|
/**
|
||||||
* Return search resource definitions.
|
* Return search resource definitions.
|
||||||
*
|
*
|
||||||
|
* Supported SQL placeholders (replaced before parameter binding):
|
||||||
|
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
|
||||||
|
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
|
||||||
|
*
|
||||||
|
* All remaining `?` placeholders are bound to the LIKE search term.
|
||||||
|
* The last `?` in preview_sql is reserved for the preview limit.
|
||||||
|
*
|
||||||
* @return array<string, array{
|
* @return array<string, array{
|
||||||
* label: string,
|
* label: string,
|
||||||
* permission: string,
|
* permission: string,
|
||||||
|
|||||||
56
lib/App/Module/ModuleAutoloader.php
Normal file
56
lib/App/Module/ModuleAutoloader.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime autoloader for module-local PHP classes.
|
||||||
|
*
|
||||||
|
* Modules can place PHP code under modules/<id>/lib and use the same
|
||||||
|
* MintyPHP namespace root as core classes.
|
||||||
|
*/
|
||||||
|
final class ModuleAutoloader
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private static array $moduleLibDirs = [];
|
||||||
|
|
||||||
|
private static bool $registered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ModuleManifest> $manifests
|
||||||
|
*/
|
||||||
|
public static function register(array $manifests): void
|
||||||
|
{
|
||||||
|
$dirs = self::$moduleLibDirs;
|
||||||
|
foreach ($manifests as $manifest) {
|
||||||
|
$libDir = rtrim($manifest->basePath, '/') . '/lib';
|
||||||
|
if (is_dir($libDir)) {
|
||||||
|
$dirs[] = $libDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::$moduleLibDirs = array_values(array_unique($dirs));
|
||||||
|
|
||||||
|
if (self::$registered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spl_autoload_register([self::class, 'autoload'], true, true);
|
||||||
|
self::$registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function autoload(string $class): void
|
||||||
|
{
|
||||||
|
$prefix = 'MintyPHP\\';
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||||
|
foreach (self::$moduleLibDirs as $libDir) {
|
||||||
|
$candidate = $libDir . '/' . $relativePath;
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
require_once $candidate;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,22 @@ final class ModuleManifest
|
|||||||
/** @var array<string, list<string>> Asset group name → file list */
|
/** @var array<string, list<string>> Asset group name → file list */
|
||||||
public readonly array $assetGroups;
|
public readonly array $assetGroups;
|
||||||
|
|
||||||
/** @var list<array{name: string, handler: string, schedule?: string}> */
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* job_key: string,
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
public readonly array $schedulerJobs;
|
public readonly array $schedulerJobs;
|
||||||
|
|
||||||
/** @var list<class-string> LayoutContextProvider FQCNs */
|
/** @var list<class-string> LayoutContextProvider FQCNs */
|
||||||
@@ -44,9 +59,22 @@ final class ModuleManifest
|
|||||||
/** @var list<class-string> SessionProvider FQCNs */
|
/** @var list<class-string> SessionProvider FQCNs */
|
||||||
public readonly array $sessionProviders;
|
public readonly array $sessionProviders;
|
||||||
|
|
||||||
/** @var list<string> Permission keys (e.g. 'address_book.view') */
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* key: string,
|
||||||
|
* description: string,
|
||||||
|
* active: int,
|
||||||
|
* is_system: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
public readonly array $permissions;
|
public readonly array $permissions;
|
||||||
|
|
||||||
|
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
||||||
|
public readonly array $authorizationPolicies;
|
||||||
|
|
||||||
|
/** @var array<string, string> UI-capability-key → ability-string for layout authorization */
|
||||||
|
public readonly array $layoutCapabilities;
|
||||||
|
|
||||||
public readonly ?string $migrationsPath;
|
public readonly ?string $migrationsPath;
|
||||||
|
|
||||||
public readonly string $basePath;
|
public readonly string $basePath;
|
||||||
@@ -76,10 +104,12 @@ final class ModuleManifest
|
|||||||
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
|
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
|
||||||
$this->searchResources = self::listOf($raw, 'search_resources');
|
$this->searchResources = self::listOf($raw, 'search_resources');
|
||||||
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
|
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
|
||||||
$this->schedulerJobs = self::arrayOf($raw, 'scheduler_jobs');
|
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
|
||||||
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
||||||
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
||||||
$this->permissions = self::listOf($raw, 'permissions');
|
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
||||||
|
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
||||||
|
$this->layoutCapabilities = is_array($raw['layout_capabilities'] ?? null) ? $raw['layout_capabilities'] : [];
|
||||||
|
|
||||||
$migrationsPath = $raw['migrations_path'] ?? null;
|
$migrationsPath = $raw['migrations_path'] ?? null;
|
||||||
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
||||||
@@ -112,4 +142,224 @@ final class ModuleManifest
|
|||||||
$value = $raw[$key] ?? [];
|
$value = $raw[$key] ?? [];
|
||||||
return is_array($value) ? $value : [];
|
return is_array($value) ? $value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||||
|
*/
|
||||||
|
private static function normalizePermissions(mixed $value, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = [];
|
||||||
|
foreach (array_values($value) as $index => $permission) {
|
||||||
|
if (!is_array($permission)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim((string) ($permission['key'] ?? ''));
|
||||||
|
$description = trim((string) ($permission['description'] ?? ''));
|
||||||
|
if ($key === '' || $description === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'description' => $description,
|
||||||
|
'active' => (int) ((bool) ($permission['active'] ?? true)),
|
||||||
|
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* job_key: string,
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
foreach (array_values($value) as $index => $job) {
|
||||||
|
if (!is_array($job)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiredKeys = [
|
||||||
|
'job_key',
|
||||||
|
'handler',
|
||||||
|
'label',
|
||||||
|
'description',
|
||||||
|
'default_enabled',
|
||||||
|
'default_timezone',
|
||||||
|
'default_schedule_type',
|
||||||
|
'default_schedule_interval',
|
||||||
|
'default_schedule_time',
|
||||||
|
'default_schedule_weekdays_csv',
|
||||||
|
'default_catchup_once',
|
||||||
|
'allowed_schedule_types',
|
||||||
|
];
|
||||||
|
foreach ($requiredKeys as $requiredKey) {
|
||||||
|
if (!array_key_exists($requiredKey, $job)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobKey = trim((string) ($job['job_key'] ?? ''));
|
||||||
|
$handler = trim((string) ($job['handler'] ?? ''));
|
||||||
|
$label = trim((string) ($job['label'] ?? ''));
|
||||||
|
$description = trim((string) ($job['description'] ?? ''));
|
||||||
|
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
|
||||||
|
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
|
||||||
|
|
||||||
|
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
|
||||||
|
$moduleId,
|
||||||
|
$index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
|
||||||
|
if (!in_array($defaultType, $allowedTypes, true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
|
||||||
|
$moduleId,
|
||||||
|
$index,
|
||||||
|
$defaultType
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultInterval = (int) $job['default_schedule_interval'];
|
||||||
|
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
|
||||||
|
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
|
||||||
|
$defaultWeekdays = self::normalizeWeekdaysCsv(
|
||||||
|
$job['default_schedule_weekdays_csv'],
|
||||||
|
$moduleId,
|
||||||
|
$index
|
||||||
|
);
|
||||||
|
|
||||||
|
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs[] = [
|
||||||
|
'job_key' => $jobKey,
|
||||||
|
'handler' => $handler,
|
||||||
|
'label' => $label,
|
||||||
|
'description' => $description,
|
||||||
|
'default_enabled' => (int) ((bool) $job['default_enabled']),
|
||||||
|
'default_timezone' => $defaultTimezone,
|
||||||
|
'default_schedule_type' => $defaultType,
|
||||||
|
'default_schedule_interval' => $defaultInterval,
|
||||||
|
'default_schedule_time' => $defaultTime,
|
||||||
|
'default_schedule_weekdays_csv' => $defaultWeekdays,
|
||||||
|
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
|
||||||
|
'allowed_schedule_types' => $allowedTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
|
||||||
|
{
|
||||||
|
if (!is_array($value) || $value === []) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = [];
|
||||||
|
foreach ($value as $rawType) {
|
||||||
|
$type = strtolower(trim((string) $rawType));
|
||||||
|
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$types[] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = array_values(array_unique($types));
|
||||||
|
sort($types, SORT_STRING);
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
||||||
|
{
|
||||||
|
$raw = trim((string) $value);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
|
||||||
|
if ($days === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedDays = [];
|
||||||
|
foreach ($days as $day) {
|
||||||
|
if (!preg_match('/^[1-7]$/', $day)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$normalizedDays[] = (int) $day;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedDays = array_values(array_unique($normalizedDays));
|
||||||
|
sort($normalizedDays, SORT_NUMERIC);
|
||||||
|
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
lib/App/Module/ModulePermissionSynchronizer.php
Normal file
91
lib/App/Module/ModulePermissionSynchronizer.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes module-defined permissions into the permissions table.
|
||||||
|
*/
|
||||||
|
final class ModulePermissionSynchronizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModuleRegistry $moduleRegistry,
|
||||||
|
private readonly PermissionRepositoryInterface $permissionRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, updated: int, unchanged: int, total: int}
|
||||||
|
*/
|
||||||
|
public function sync(): array
|
||||||
|
{
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$unchanged = 0;
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
|
||||||
|
$total++;
|
||||||
|
$key = trim((string) $permissionDefinition['key']);
|
||||||
|
if ($key === '') {
|
||||||
|
throw new \RuntimeException('Module permission definition has empty key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$desired = [
|
||||||
|
'key' => $key,
|
||||||
|
'description' => trim((string) $permissionDefinition['description']),
|
||||||
|
'active' => (int) $permissionDefinition['active'],
|
||||||
|
'is_system' => (int) $permissionDefinition['is_system'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->permissionRepository->findByKey($key);
|
||||||
|
if (!is_array($existing)) {
|
||||||
|
$createdId = $this->permissionRepository->create($desired);
|
||||||
|
if ($createdId === null) {
|
||||||
|
throw new \RuntimeException("Failed to create module permission '{$key}'.");
|
||||||
|
}
|
||||||
|
$created++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingId = (int) ($existing['id'] ?? 0);
|
||||||
|
if ($existingId <= 0) {
|
||||||
|
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPermissionEqual($existing, $desired)) {
|
||||||
|
$unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
|
||||||
|
if (!$wasUpdated) {
|
||||||
|
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
|
||||||
|
}
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'created' => $created,
|
||||||
|
'updated' => $updated,
|
||||||
|
'unchanged' => $unchanged,
|
||||||
|
'total' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $existing
|
||||||
|
* @param array<string, mixed> $desired
|
||||||
|
*/
|
||||||
|
private function isPermissionEqual(array $existing, array $desired): bool
|
||||||
|
{
|
||||||
|
$existingDescription = trim((string) ($existing['description'] ?? ''));
|
||||||
|
$existingActive = (int) ($existing['active'] ?? 0);
|
||||||
|
$existingSystem = (int) ($existing['is_system'] ?? 0);
|
||||||
|
|
||||||
|
return $existingDescription === (string) ($desired['description'] ?? '')
|
||||||
|
&& $existingActive === (int) ($desired['active'] ?? 0)
|
||||||
|
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,21 @@ use RuntimeException;
|
|||||||
*/
|
*/
|
||||||
final class ModuleRegistry
|
final class ModuleRegistry
|
||||||
{
|
{
|
||||||
|
/** @var array<string, list<string>> */
|
||||||
|
private const UI_SLOT_REQUIRED_KEYS = [
|
||||||
|
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
|
||||||
|
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
|
||||||
|
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
|
||||||
|
'topbar.right_item' => ['key', 'template'],
|
||||||
|
'layout.body_end_template' => ['key', 'template'],
|
||||||
|
'layout.head_style' => ['key', 'path'],
|
||||||
|
'runtime.component' => ['key', 'script'],
|
||||||
|
];
|
||||||
|
|
||||||
/** @var array<string, ModuleManifest> keyed by module id */
|
/** @var array<string, ModuleManifest> keyed by module id */
|
||||||
private array $modules = [];
|
private array $modules = [];
|
||||||
|
|
||||||
/** @var array<int, array{path: string, target: string, public?: bool}> merged routes */
|
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
|
||||||
private array $mergedRoutes = [];
|
private array $mergedRoutes = [];
|
||||||
|
|
||||||
/** @var list<string> merged public paths */
|
/** @var list<string> merged public paths */
|
||||||
@@ -42,12 +53,53 @@ final class ModuleRegistry
|
|||||||
/** @var list<class-string> merged session provider FQCNs */
|
/** @var list<class-string> merged session provider FQCNs */
|
||||||
private array $mergedSessionProviders = [];
|
private array $mergedSessionProviders = [];
|
||||||
|
|
||||||
/** @var list<string> merged permissions */
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* key: string,
|
||||||
|
* description: string,
|
||||||
|
* active: int,
|
||||||
|
* is_system: int
|
||||||
|
* }> merged permissions
|
||||||
|
*/
|
||||||
private array $mergedPermissions = [];
|
private array $mergedPermissions = [];
|
||||||
|
|
||||||
/** @var list<array{name: string, handler: string, schedule?: string}> */
|
/** @var list<class-string> merged authorization policy FQCNs */
|
||||||
|
private array $mergedAuthorizationPolicies = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> merged layout capabilities: UI-key → ability */
|
||||||
|
private array $mergedLayoutCapabilities = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* job_key: string,
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>,
|
||||||
|
* module_id: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
private array $mergedSchedulerJobs = [];
|
private array $mergedSchedulerJobs = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> route target => owning module id */
|
||||||
|
private array $routeTargetOwners = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> route path => owning module id */
|
||||||
|
private array $routePathOwners = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> permission key => owning module id */
|
||||||
|
private array $permissionOwners = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> scheduler job key => owning module id */
|
||||||
|
private array $schedulerJobOwners = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot the registry from a modules directory and activation config.
|
* Boot the registry from a modules directory and activation config.
|
||||||
*
|
*
|
||||||
@@ -102,11 +154,16 @@ final class ModuleRegistry
|
|||||||
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
|
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow module-local classes (modules/<id>/lib/...) to autoload.
|
||||||
|
ModuleAutoloader::register($manifests);
|
||||||
|
|
||||||
// Register and merge
|
// Register and merge
|
||||||
foreach ($manifests as $manifest) {
|
foreach ($manifests as $manifest) {
|
||||||
$registry->registerModule($manifest);
|
$registry->registerModule($manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$registry->finalizeMergedContributions();
|
||||||
|
|
||||||
return $registry;
|
return $registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +176,17 @@ final class ModuleRegistry
|
|||||||
public static function resolveEnabledModules(array $config): array
|
public static function resolveEnabledModules(array $config): array
|
||||||
{
|
{
|
||||||
$fromConfig = $config['enabled_modules'] ?? [];
|
$fromConfig = $config['enabled_modules'] ?? [];
|
||||||
$fromEnv = trim((string) getenv('APP_ENABLED_MODULES'));
|
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
|
||||||
|
|
||||||
if ($fromEnv !== '') {
|
// Semantics:
|
||||||
$fromConfig = array_map('trim', explode(',', $fromEnv));
|
// - not set => use config/modules.php
|
||||||
|
// - set '' => explicit "no modules"
|
||||||
|
// - set list => exact list from env
|
||||||
|
if ($fromEnvRaw !== false) {
|
||||||
|
$fromEnv = trim((string) $fromEnvRaw);
|
||||||
|
$fromConfig = $fromEnv === ''
|
||||||
|
? []
|
||||||
|
: array_map('trim', explode(',', $fromEnv));
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
|
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
|
||||||
@@ -139,15 +203,28 @@ final class ModuleRegistry
|
|||||||
$this->modules[$manifest->id] = $manifest;
|
$this->modules[$manifest->id] = $manifest;
|
||||||
|
|
||||||
// — Merge routes (fail-fast on collision) ——————————————
|
// — Merge routes (fail-fast on collision) ——————————————
|
||||||
$existingTargets = array_column($this->mergedRoutes, 'target');
|
foreach ($manifest->routes as $rawRoute) {
|
||||||
foreach ($manifest->routes as $route) {
|
$route = $this->normalizeRoute($rawRoute, $manifest->id);
|
||||||
|
$path = $route['path'];
|
||||||
$target = $route['target'];
|
$target = $route['target'];
|
||||||
if (in_array($target, $existingTargets, true)) {
|
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
|
||||||
|
$owner = $this->routeTargetOwners[$target];
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with an existing route."
|
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isset($this->routePathOwners[$path])) {
|
||||||
|
$owner = $this->routePathOwners[$path];
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->routeTargetOwners[$target] = $manifest->id;
|
||||||
|
$this->routePathOwners[$path] = $manifest->id;
|
||||||
$this->mergedRoutes[] = $route;
|
$this->mergedRoutes[] = $route;
|
||||||
|
if (!empty($route['public'])) {
|
||||||
|
$this->mergedPublicPaths[] = $path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Merge public paths ———————————————————————————
|
// — Merge public paths ———————————————————————————
|
||||||
@@ -163,23 +240,27 @@ final class ModuleRegistry
|
|||||||
}
|
}
|
||||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||||
foreach ($contributionList as $contribution) {
|
foreach ($contributionList as $contribution) {
|
||||||
$key = is_array($contribution) ? ($contribution['key'] ?? null) : null;
|
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id);
|
||||||
if ($key !== null) {
|
$key = (string) $normalizedContribution['key'];
|
||||||
foreach ($this->mergedUiSlots[$slotName] as $existing) {
|
foreach ($this->mergedUiSlots[$slotName] as $existing) {
|
||||||
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
|
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
|
||||||
if ($existingKey === $key) {
|
if ($existingKey === $key) {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
|
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->mergedUiSlots[$slotName][] = $contribution;
|
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Merge search resources (fail-fast on duplicate provider) ——
|
// — Merge search resources (fail-fast on duplicate provider) ——
|
||||||
foreach ($manifest->searchResources as $provider) {
|
foreach ($manifest->searchResources as $provider) {
|
||||||
|
if (!is_string($provider) || trim($provider) === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||||
|
);
|
||||||
|
}
|
||||||
if (in_array($provider, $this->mergedSearchResources, true)) {
|
if (in_array($provider, $this->mergedSearchResources, true)) {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
|
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
|
||||||
@@ -200,22 +281,283 @@ final class ModuleRegistry
|
|||||||
|
|
||||||
// — Merge permissions (fail-fast on duplicate) ———————
|
// — Merge permissions (fail-fast on duplicate) ———————
|
||||||
foreach ($manifest->permissions as $permission) {
|
foreach ($manifest->permissions as $permission) {
|
||||||
if (in_array($permission, $this->mergedPermissions, true)) {
|
$permissionKey = trim((string) $permission['key']);
|
||||||
|
if ($permissionKey === '') {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Permission conflict: '{$permission}' from module '{$manifest->id}' is already registered."
|
"Permission in module '{$manifest->id}' must define non-empty 'key'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$this->mergedPermissions[] = $permission;
|
|
||||||
|
if (isset($this->permissionOwners[$permissionKey])) {
|
||||||
|
$owner = $this->permissionOwners[$permissionKey];
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->permissionOwners[$permissionKey] = $manifest->id;
|
||||||
|
$this->mergedPermissions[] = [
|
||||||
|
'key' => $permissionKey,
|
||||||
|
'description' => trim((string) $permission['description']),
|
||||||
|
'active' => (int) $permission['active'],
|
||||||
|
'is_system' => (int) $permission['is_system'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Merge layout context providers ————————————————
|
// — Merge layout context providers ————————————————
|
||||||
array_push($this->mergedLayoutContextProviders, ...$manifest->layoutContextProviders);
|
foreach ($manifest->layoutContextProviders as $providerClass) {
|
||||||
|
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->mergedLayoutContextProviders[] = $providerClass;
|
||||||
|
}
|
||||||
|
|
||||||
// — Merge session providers ——————————————————————
|
// — Merge session providers ——————————————————————
|
||||||
array_push($this->mergedSessionProviders, ...$manifest->sessionProviders);
|
foreach ($manifest->sessionProviders as $providerClass) {
|
||||||
|
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->mergedSessionProviders[] = $providerClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — Merge authorization policies (fail-fast on duplicate) ———
|
||||||
|
foreach ($manifest->authorizationPolicies as $policyClass) {
|
||||||
|
if (!is_string($policyClass) || trim($policyClass) === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->mergedAuthorizationPolicies[] = $policyClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — Merge layout capabilities (fail-fast on duplicate key) ———
|
||||||
|
foreach ($manifest->layoutCapabilities as $uiKey => $ability) {
|
||||||
|
$uiKey = trim((string) $uiKey);
|
||||||
|
$ability = trim((string) $ability);
|
||||||
|
if ($uiKey === '' || $ability === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Layout capability in module '{$manifest->id}' must have non-empty key and ability."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isset($this->mergedLayoutCapabilities[$uiKey])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Layout capability conflict: key '{$uiKey}' from module '{$manifest->id}' is already registered."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->mergedLayoutCapabilities[$uiKey] = $ability;
|
||||||
|
}
|
||||||
|
|
||||||
// — Merge scheduler jobs —————————————————————————
|
// — Merge scheduler jobs —————————————————————————
|
||||||
array_push($this->mergedSchedulerJobs, ...$manifest->schedulerJobs);
|
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
||||||
|
$jobKey = trim((string) $schedulerJob['job_key']);
|
||||||
|
if ($jobKey === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isset($this->schedulerJobOwners[$jobKey])) {
|
||||||
|
$owner = $this->schedulerJobOwners[$jobKey];
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->schedulerJobOwners[$jobKey] = $manifest->id;
|
||||||
|
$this->mergedSchedulerJobs[] = [
|
||||||
|
'job_key' => $jobKey,
|
||||||
|
'handler' => trim((string) $schedulerJob['handler']),
|
||||||
|
'label' => trim((string) $schedulerJob['label']),
|
||||||
|
'description' => trim((string) $schedulerJob['description']),
|
||||||
|
'default_enabled' => (int) $schedulerJob['default_enabled'],
|
||||||
|
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
|
||||||
|
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
|
||||||
|
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
|
||||||
|
'default_schedule_time' => $schedulerJob['default_schedule_time'],
|
||||||
|
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
|
||||||
|
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
|
||||||
|
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
|
||||||
|
'module_id' => $manifest->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeMergedContributions(): void
|
||||||
|
{
|
||||||
|
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
||||||
|
$this->mergedPublicPaths,
|
||||||
|
static fn (mixed $path): bool => trim((string) $path) !== ''
|
||||||
|
)));
|
||||||
|
|
||||||
|
foreach ($this->mergedUiSlots as $slotName => $items) {
|
||||||
|
usort($items, static function (array $a, array $b): int {
|
||||||
|
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
|
||||||
|
if ($orderCmp !== 0) {
|
||||||
|
return $orderCmp;
|
||||||
|
}
|
||||||
|
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
|
||||||
|
if ($moduleCmp !== 0) {
|
||||||
|
return $moduleCmp;
|
||||||
|
}
|
||||||
|
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
unset($item['__module_id']);
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
|
$this->mergedUiSlots[$slotName] = $items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{path: string, target: string, module_id: string, public?: bool}
|
||||||
|
*/
|
||||||
|
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($rawRoute)) {
|
||||||
|
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim((string) ($rawRoute['path'] ?? ''));
|
||||||
|
$target = trim((string) ($rawRoute['target'] ?? ''));
|
||||||
|
if ($path === '' || $target === '') {
|
||||||
|
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [
|
||||||
|
'path' => $path,
|
||||||
|
'target' => $target,
|
||||||
|
'module_id' => $moduleId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (array_key_exists('public', $rawRoute)) {
|
||||||
|
$normalized['public'] = (bool) $rawRoute['public'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($rawContribution)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim((string) ($rawContribution['key'] ?? ''));
|
||||||
|
if ($key === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::UI_SLOT_REQUIRED_KEYS[$slotName] ?? [] as $requiredKey) {
|
||||||
|
$requiredValue = trim((string) ($rawContribution[$requiredKey] ?? ''));
|
||||||
|
if ($requiredValue === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' is missing required key '{$requiredKey}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateUiSlotContributionByType($slotName, $rawContribution, $moduleId, $key);
|
||||||
|
|
||||||
|
$normalized = $rawContribution;
|
||||||
|
$normalized['key'] = $key;
|
||||||
|
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
|
||||||
|
$normalized['__module_id'] = $moduleId;
|
||||||
|
$normalized['module_id'] = $moduleId;
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $rawContribution
|
||||||
|
*/
|
||||||
|
private function validateUiSlotContributionByType(
|
||||||
|
string $slotName,
|
||||||
|
array $rawContribution,
|
||||||
|
string $moduleId,
|
||||||
|
string $slotKey
|
||||||
|
): void {
|
||||||
|
if (in_array($slotName, ['aside.tab_panel', 'topbar.right_item', 'layout.body_end_template'], true)) {
|
||||||
|
$templatePath = trim((string) ($rawContribution['panel_template'] ?? $rawContribution['template'] ?? ''));
|
||||||
|
if ($templatePath === '' || !is_file($templatePath)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot '{$slotName}' in module '{$moduleId}' references missing template '{$templatePath}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slotName === 'layout.head_style') {
|
||||||
|
$path = trim((string) ($rawContribution['path'] ?? ''));
|
||||||
|
if ($path === '' || str_contains($path, '..')) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'layout.head_style' in module '{$moduleId}' has invalid path '{$path}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slotName === 'user.edit.aside_action') {
|
||||||
|
$type = strtolower(trim((string) ($rawContribution['type'] ?? 'link')));
|
||||||
|
if ($type !== 'link' && $type !== 'form') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' has invalid type '{$type}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($type === 'form') {
|
||||||
|
$action = trim((string) ($rawContribution['action'] ?? ''));
|
||||||
|
if ($action === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'action' for type=form."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$href = trim((string) ($rawContribution['href'] ?? ''));
|
||||||
|
if ($href === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'href' for type=link."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slotName === 'runtime.component') {
|
||||||
|
$script = trim((string) ($rawContribution['script'] ?? ''));
|
||||||
|
if ($script === '' || str_contains($script, '..')) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid script '{$script}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phase = strtolower(trim((string) ($rawContribution['phase'] ?? 'late')));
|
||||||
|
if (!in_array($phase, ['early', 'default', 'late'], true)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid phase '{$phase}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = trim((string) ($rawContribution['config_path'] ?? ''));
|
||||||
|
if ($configPath !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/', $configPath)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid config_path '{$configPath}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Getters ────────────────────────────────────────────────────────
|
// ─── Getters ────────────────────────────────────────────────────────
|
||||||
@@ -239,7 +581,7 @@ final class ModuleRegistry
|
|||||||
return $this->modules[$id];
|
return $this->modules[$id];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, array{path: string, target: string, public?: bool}> */
|
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
|
||||||
public function getRoutes(): array
|
public function getRoutes(): array
|
||||||
{
|
{
|
||||||
return $this->mergedRoutes;
|
return $this->mergedRoutes;
|
||||||
@@ -295,13 +637,52 @@ final class ModuleRegistry
|
|||||||
return $this->mergedSessionProviders;
|
return $this->mergedSessionProviders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/**
|
||||||
|
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||||
|
*/
|
||||||
public function getPermissions(): array
|
public function getPermissions(): array
|
||||||
{
|
{
|
||||||
return $this->mergedPermissions;
|
return $this->mergedPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<array{name: string, handler: string, schedule?: string}> */
|
/** @return list<string> */
|
||||||
|
public function getPermissionKeys(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $permission): string => (string) $permission['key'],
|
||||||
|
$this->mergedPermissions
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<class-string> */
|
||||||
|
public function getAuthorizationPolicies(): array
|
||||||
|
{
|
||||||
|
return $this->mergedAuthorizationPolicies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> UI-capability-key → ability-string */
|
||||||
|
public function getLayoutCapabilities(): array
|
||||||
|
{
|
||||||
|
return $this->mergedLayoutCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* job_key: string,
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>,
|
||||||
|
* module_id: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
public function getSchedulerJobs(): array
|
public function getSchedulerJobs(): array
|
||||||
{
|
{
|
||||||
return $this->mergedSchedulerJobs;
|
return $this->mergedSchedulerJobs;
|
||||||
|
|||||||
178
lib/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
178
lib/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes module web assets to a runtime web/modules directory.
|
||||||
|
*/
|
||||||
|
final class ModuleRuntimeAssetPublisher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, ModuleManifest> $modules
|
||||||
|
* @return array{created: int, updated: int, removed: int, unchanged: int}
|
||||||
|
*/
|
||||||
|
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
|
||||||
|
{
|
||||||
|
$normalizedMode = strtolower(trim($mode));
|
||||||
|
if ($normalizedMode === '') {
|
||||||
|
$normalizedMode = 'symlink';
|
||||||
|
}
|
||||||
|
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
|
||||||
|
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
|
||||||
|
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
|
||||||
|
}
|
||||||
|
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
|
||||||
|
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
|
||||||
|
|
||||||
|
$desired = [];
|
||||||
|
foreach ($modules as $manifest) {
|
||||||
|
if (!$manifest instanceof ModuleManifest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
|
||||||
|
if (!is_dir($moduleWebDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$desired[$manifest->id] = $moduleWebDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($runtimeModulesDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
|
||||||
|
}
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($desired[$entry])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $runtimeModulesDir . '/' . $entry;
|
||||||
|
$this->removePath($path);
|
||||||
|
$result['removed']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($desired as $moduleId => $moduleWebDir) {
|
||||||
|
$targetPath = $runtimeModulesDir . '/' . $moduleId;
|
||||||
|
$targetExists = file_exists($targetPath) || is_link($targetPath);
|
||||||
|
|
||||||
|
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
|
||||||
|
$targetReal = realpath($targetPath);
|
||||||
|
$sourceReal = realpath($moduleWebDir);
|
||||||
|
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
|
||||||
|
$result['unchanged']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetExists) {
|
||||||
|
$this->removePath($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedMode === 'symlink') {
|
||||||
|
if (!@symlink($moduleWebDir, $targetPath)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
|
||||||
|
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->copyDirectory($moduleWebDir, $targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetExists) {
|
||||||
|
$result['updated']++;
|
||||||
|
} else {
|
||||||
|
$result['created']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removePath(string $path): void
|
||||||
|
{
|
||||||
|
if (is_link($path)) {
|
||||||
|
if (!unlink($path)) {
|
||||||
|
throw new RuntimeException("Cannot remove symlink: {$path}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
if (!unlink($path)) {
|
||||||
|
throw new RuntimeException("Cannot remove file: {$path}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($path);
|
||||||
|
if ($entries === false) {
|
||||||
|
throw new RuntimeException("Cannot scan directory: {$path}");
|
||||||
|
}
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->removePath($path . '/' . $entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rmdir($path)) {
|
||||||
|
throw new RuntimeException("Cannot remove directory: {$path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyDirectory(string $sourceDir, string $targetDir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($sourceDir)) {
|
||||||
|
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
|
||||||
|
}
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||||
|
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($sourceDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $sourceDir . '/' . $entry;
|
||||||
|
$target = $targetDir . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_link($source)) {
|
||||||
|
$linkTarget = readlink($source);
|
||||||
|
if ($linkTarget === false || !symlink($linkTarget, $target)) {
|
||||||
|
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_dir($source)) {
|
||||||
|
$this->copyDirectory($source, $target);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_file($source)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!copy($source, $target)) {
|
||||||
|
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
lib/App/Module/ModuleRuntimePageBuilder.php
Normal file
135
lib/App/Module/ModuleRuntimePageBuilder.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
use FilesystemIterator;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
||||||
|
*/
|
||||||
|
final class ModuleRuntimePageBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, ModuleManifest> $modules
|
||||||
|
* @return array{core_entries: int, module_entries: int}
|
||||||
|
*/
|
||||||
|
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
|
||||||
|
{
|
||||||
|
$corePages = realpath($corePagesDir);
|
||||||
|
if ($corePages === false) {
|
||||||
|
throw new RuntimeException('Core pages/ directory not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($modules) === 0) {
|
||||||
|
$this->pointRuntimeToCore($runtimeDir, $corePages);
|
||||||
|
return ['core_entries' => 0, 'module_entries' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_link($runtimeDir)) {
|
||||||
|
unlink($runtimeDir);
|
||||||
|
}
|
||||||
|
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
|
||||||
|
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeDirectoryContents($runtimeDir);
|
||||||
|
|
||||||
|
$coreEntries = scandir($corePages);
|
||||||
|
if ($coreEntries === false) {
|
||||||
|
throw new RuntimeException('Cannot scan core pages directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$coreCount = 0;
|
||||||
|
foreach ($coreEntries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$source = $corePages . '/' . $entry;
|
||||||
|
$target = $runtimeDir . '/' . $entry;
|
||||||
|
if (!@symlink($source, $target)) {
|
||||||
|
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||||
|
}
|
||||||
|
$coreCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulePageCount = 0;
|
||||||
|
foreach ($modules as $manifest) {
|
||||||
|
$modulePagesDir = $manifest->basePath . '/pages';
|
||||||
|
if (!is_dir($modulePagesDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entries = scandir($modulePagesDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$source = $modulePagesDir . '/' . $entry;
|
||||||
|
$target = $runtimeDir . '/' . $entry;
|
||||||
|
if (file_exists($target) || is_link($target)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!@symlink($source, $target)) {
|
||||||
|
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||||
|
}
|
||||||
|
$modulePageCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'core_entries' => $coreCount,
|
||||||
|
'module_entries' => $modulePageCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||||
|
{
|
||||||
|
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
||||||
|
$this->removeDirectoryContents($runtimeDir);
|
||||||
|
rmdir($runtimeDir);
|
||||||
|
}
|
||||||
|
if (is_link($runtimeDir)) {
|
||||||
|
unlink($runtimeDir);
|
||||||
|
}
|
||||||
|
if (!@symlink($corePages, $runtimeDir)) {
|
||||||
|
throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectoryContents(string $dir): void
|
||||||
|
{
|
||||||
|
$entries = scandir($dir);
|
||||||
|
if ($entries === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir . '/' . $entry;
|
||||||
|
if (is_link($path)) {
|
||||||
|
unlink($path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$items = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
rmdir($path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,9 @@ $moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
|||||||
|
|
||||||
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
||||||
|
|
||||||
$coreKeys = []; // snapshot keys before module registrars
|
// Disallow overriding existing core bindings from module registrars.
|
||||||
// We use reflection-free approach: track what the container had before modules
|
$container->protectExistingBindings();
|
||||||
|
|
||||||
foreach ($moduleRegistry->getContainerRegistrars() as $registrarClass) {
|
foreach ($moduleRegistry->getContainerRegistrars() as $registrarClass) {
|
||||||
if (!class_exists($registrarClass)) {
|
if (!class_exists($registrarClass)) {
|
||||||
throw new RuntimeException("Module container registrar class not found: {$registrarClass}");
|
throw new RuntimeException("Module container registrar class not found: {$registrarClass}");
|
||||||
|
|||||||
@@ -66,10 +66,15 @@ class AccessPolicyFactory
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assembles all policies into a single AuthorizationService.
|
// Assembles all policies into a single AuthorizationService.
|
||||||
// Policy order doesn't matter — each ability maps to exactly one policy via supports().
|
// Core policies are registered first; module-contributed policies are appended.
|
||||||
|
// Each ability maps to exactly one policy via supports() — first match wins.
|
||||||
public function createAuthorizationService(): AuthorizationService
|
public function createAuthorizationService(): AuthorizationService
|
||||||
{
|
{
|
||||||
return $this->authorizationService ??= new AuthorizationService([
|
if ($this->authorizationService !== null) {
|
||||||
|
return $this->authorizationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policies = [
|
||||||
$this->createUserAuthorizationPolicy(),
|
$this->createUserAuthorizationPolicy(),
|
||||||
$this->createRoleAuthorizationPolicy(),
|
$this->createRoleAuthorizationPolicy(),
|
||||||
$this->createPermissionAuthorizationPolicy(),
|
$this->createPermissionAuthorizationPolicy(),
|
||||||
@@ -77,6 +82,50 @@ class AccessPolicyFactory
|
|||||||
$this->createSettingsAuthorizationPolicy(),
|
$this->createSettingsAuthorizationPolicy(),
|
||||||
$this->createTenantAuthorizationPolicy(),
|
$this->createTenantAuthorizationPolicy(),
|
||||||
$this->createDepartmentAuthorizationPolicy(),
|
$this->createDepartmentAuthorizationPolicy(),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
// Append module-contributed authorization policies.
|
||||||
|
try {
|
||||||
|
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
|
||||||
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
|
foreach ($registry->getAuthorizationPolicies() as $policyClass) {
|
||||||
|
if (!is_string($policyClass) || trim($policyClass) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$policy = $this->instantiateModulePolicy($policyClass);
|
||||||
|
if ($policy instanceof AuthorizationPolicyInterface) {
|
||||||
|
$policies[] = $policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// fail-open: no module registry available in this context
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->authorizationService = new AuthorizationService($policies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function instantiateModulePolicy(string $policyClass): ?AuthorizationPolicyInterface
|
||||||
|
{
|
||||||
|
if (!class_exists($policyClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionClass($policyClass);
|
||||||
|
$constructor = $reflection->getConstructor();
|
||||||
|
if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) {
|
||||||
|
$instance = $reflection->newInstance();
|
||||||
|
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($constructor->getNumberOfRequiredParameters() === 1) {
|
||||||
|
$instance = $reflection->newInstance($this->permissionService);
|
||||||
|
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace MintyPHP\Service\Access;
|
|||||||
|
|
||||||
final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterface
|
final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||||
{
|
{
|
||||||
public const ABILITY_ADDRESS_BOOK_VIEW = 'ops.address_book.view';
|
|
||||||
public const ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW = 'ops.admin.user_lifecycle_audit.view';
|
public const ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW = 'ops.admin.user_lifecycle_audit.view';
|
||||||
public const ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE = 'ops.admin.users.lifecycle_restore';
|
public const ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE = 'ops.admin.users.lifecycle_restore';
|
||||||
public const ABILITY_ADMIN_API_AUDIT_VIEW = 'ops.admin.api_audit.view';
|
public const ABILITY_ADMIN_API_AUDIT_VIEW = 'ops.admin.api_audit.view';
|
||||||
@@ -49,7 +48,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
|
|||||||
public function supports(string $ability): bool
|
public function supports(string $ability): bool
|
||||||
{
|
{
|
||||||
return in_array($ability, [
|
return in_array($ability, [
|
||||||
self::ABILITY_ADDRESS_BOOK_VIEW,
|
|
||||||
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
||||||
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
|
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
|
||||||
self::ABILITY_ADMIN_API_AUDIT_VIEW,
|
self::ABILITY_ADMIN_API_AUDIT_VIEW,
|
||||||
@@ -98,7 +96,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
|
|||||||
return match ($ability) {
|
return match ($ability) {
|
||||||
self::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS => $this->authorizeUsersCreateCustomFields($actorUserId),
|
self::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS => $this->authorizeUsersCreateCustomFields($actorUserId),
|
||||||
self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId),
|
self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId),
|
||||||
self::ABILITY_ADDRESS_BOOK_VIEW => $this->allowIfHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW),
|
|
||||||
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW),
|
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW),
|
||||||
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE),
|
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE),
|
||||||
self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW),
|
self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW),
|
||||||
|
|||||||
@@ -80,11 +80,28 @@ final class UiAccessService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Resolve layout capabilities from Core + module-contributed capabilities.
|
||||||
|
*
|
||||||
* @return array<string, bool>
|
* @return array<string, bool>
|
||||||
*/
|
*/
|
||||||
public function layoutCapabilities(int $actorUserId): array
|
public function layoutCapabilities(int $actorUserId): array
|
||||||
{
|
{
|
||||||
return $this->resolveMap($actorUserId, UiCapabilityMap::LAYOUT);
|
$map = UiCapabilityMap::LAYOUT;
|
||||||
|
|
||||||
|
// Merge module-contributed layout capabilities dynamically.
|
||||||
|
try {
|
||||||
|
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
|
||||||
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
|
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
|
||||||
|
foreach ($registry->getLayoutCapabilities() as $key => $ability) {
|
||||||
|
$map[$key] = $ability;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// fail-open: no module registry available in this context
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveMap($actorUserId, $map);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ namespace MintyPHP\Service\Access;
|
|||||||
final class UiCapabilityMap
|
final class UiCapabilityMap
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* Core layout capabilities — admin panel visibility checks.
|
||||||
|
*
|
||||||
|
* Module-contributed capabilities are registered via the module manifest's
|
||||||
|
* 'layout_capabilities' field and merged dynamically in UiAccessService.
|
||||||
|
*
|
||||||
* @var array<string, string>
|
* @var array<string, string>
|
||||||
*/
|
*/
|
||||||
public const LAYOUT = [
|
public const LAYOUT = [
|
||||||
@@ -24,7 +29,6 @@ final class UiCapabilityMap
|
|||||||
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
|
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
|
||||||
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
||||||
'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
|
'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
|
||||||
'can_view_address_book' => OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class AuthService
|
|||||||
|
|
||||||
$sessionUser = $this->sessionUser();
|
$sessionUser = $this->sessionUser();
|
||||||
if (!($sessionUser['active'] ?? 1)) {
|
if (!($sessionUser['active'] ?? 1)) {
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||||
'error_code' => 'account_inactive',
|
'error_code' => 'account_inactive',
|
||||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||||
@@ -96,7 +96,7 @@ class AuthService
|
|||||||
|
|
||||||
$userId = (int) ($sessionUser['id'] ?? 0);
|
$userId = (int) ($sessionUser['id'] ?? 0);
|
||||||
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
|
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||||
'error_code' => 'password_login_disabled',
|
'error_code' => 'password_login_disabled',
|
||||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||||
@@ -111,7 +111,7 @@ class AuthService
|
|||||||
$this->permissionService->getUserPermissions($userId, true);
|
$this->permissionService->getUserPermissions($userId, true);
|
||||||
$this->loadTenantDataIntoSession($userId);
|
$this->loadTenantDataIntoSession($userId);
|
||||||
if ($this->noActiveTenant()) {
|
if ($this->noActiveTenant()) {
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||||
'error_code' => 'no_active_tenant',
|
'error_code' => 'no_active_tenant',
|
||||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||||
@@ -203,7 +203,7 @@ class AuthService
|
|||||||
$this->permissionService->getUserPermissions($userId, true);
|
$this->permissionService->getUserPermissions($userId, true);
|
||||||
$this->loadTenantDataIntoSession($userId);
|
$this->loadTenantDataIntoSession($userId);
|
||||||
if ($this->noActiveTenant()) {
|
if ($this->noActiveTenant()) {
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||||
'error_code' => 'login_no_active_tenant',
|
'error_code' => 'login_no_active_tenant',
|
||||||
'actor_user_id' => $userId,
|
'actor_user_id' => $userId,
|
||||||
@@ -277,7 +277,7 @@ class AuthService
|
|||||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||||
$actorTenantId = $this->currentTenantIdFromSession();
|
$actorTenantId = $this->currentTenantIdFromSession();
|
||||||
$this->rememberMeService->forgetCurrentUser();
|
$this->rememberMeService->forgetCurrentUser();
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.logout', 'success', [
|
$this->recordAuthEvent('auth.logout', 'success', [
|
||||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||||
@@ -290,7 +290,7 @@ class AuthService
|
|||||||
$actorTenantId = $this->currentTenantIdFromSession();
|
$actorTenantId = $this->currentTenantIdFromSession();
|
||||||
$this->sessionStore->remove('session_started_at');
|
$this->sessionStore->remove('session_started_at');
|
||||||
$this->sessionStore->remove('session_last_activity');
|
$this->sessionStore->remove('session_last_activity');
|
||||||
Auth::logout();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.session.timeout', 'success', [
|
$this->recordAuthEvent('auth.session.timeout', 'success', [
|
||||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||||
@@ -413,4 +413,10 @@ class AuthService
|
|||||||
{
|
{
|
||||||
return (bool) $this->sessionStore->get('no_active_tenant', false);
|
return (bool) $this->sessionStore->get('no_active_tenant', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function logoutWithModuleCleanup(): void
|
||||||
|
{
|
||||||
|
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||||
|
Auth::logout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\Http\CookieStoreInterface;
|
use MintyPHP\Http\CookieStoreInterface;
|
||||||
use MintyPHP\Http\RequestRuntimeInterface;
|
use MintyPHP\Http\RequestRuntimeInterface;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
|
||||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||||
use MintyPHP\Service\User\UserServicesFactory;
|
use MintyPHP\Service\User\UserServicesFactory;
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ class AuthServicesFactory
|
|||||||
private readonly SessionStoreInterface $sessionStore,
|
private readonly SessionStoreInterface $sessionStore,
|
||||||
private readonly CookieStoreInterface $cookieStore,
|
private readonly CookieStoreInterface $cookieStore,
|
||||||
private readonly RequestRuntimeInterface $requestRuntime,
|
private readonly RequestRuntimeInterface $requestRuntime,
|
||||||
private readonly BookmarkServicesFactory $bookmarkServicesFactory
|
private readonly AppContainer $appContainer
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +152,8 @@ class AuthServicesFactory
|
|||||||
{
|
{
|
||||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||||
$this->userServicesFactory->createUserTenantContextService(),
|
$this->userServicesFactory->createUserTenantContextService(),
|
||||||
$this->bookmarkServicesFactory->createBookmarkService(),
|
$this->sessionStore,
|
||||||
$this->sessionStore
|
$this->appContainer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
|
||||||
use MintyPHP\Service\User\UserTenantContextService;
|
use MintyPHP\Service\User\UserTenantContextService;
|
||||||
|
|
||||||
class AuthSessionTenantContextService
|
class AuthSessionTenantContextService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserTenantContextService $userTenantContextService,
|
private readonly UserTenantContextService $userTenantContextService,
|
||||||
private readonly BookmarkService $bookmarkService,
|
private readonly SessionStoreInterface $sessionStore,
|
||||||
private readonly SessionStoreInterface $sessionStore
|
private readonly AppContainer $container
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +25,6 @@ class AuthSessionTenantContextService
|
|||||||
|
|
||||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||||
$this->sessionStore->set('available_tenants', $availableTenants);
|
$this->sessionStore->set('available_tenants', $availableTenants);
|
||||||
$this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId));
|
|
||||||
$this->sessionStore->set('user_bookmarks', $this->bookmarkService->listGroupedForUser($userId));
|
|
||||||
|
|
||||||
if (!$availableTenants) {
|
if (!$availableTenants) {
|
||||||
$this->sessionStore->set('no_active_tenant', true);
|
$this->sessionStore->set('no_active_tenant', true);
|
||||||
@@ -43,5 +43,45 @@ class AuthSessionTenantContextService
|
|||||||
if ($currentTenant) {
|
if ($currentTenant) {
|
||||||
$this->sessionStore->set('current_tenant', $currentTenant);
|
$this->sessionStore->set('current_tenant', $currentTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = $this->sessionStore->get('user', []);
|
||||||
|
$userRecord = is_array($user) ? $user : [];
|
||||||
|
foreach ($this->moduleSessionProviders() as $provider) {
|
||||||
|
$provider->populate($userRecord, $this->container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearModuleSessionData(): void
|
||||||
|
{
|
||||||
|
foreach ($this->moduleSessionProviders() as $provider) {
|
||||||
|
$provider->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<SessionProvider>
|
||||||
|
*/
|
||||||
|
private function moduleSessionProviders(): array
|
||||||
|
{
|
||||||
|
$providers = [];
|
||||||
|
try {
|
||||||
|
$registry = $this->container->get(ModuleRegistry::class);
|
||||||
|
if (!$registry instanceof ModuleRegistry) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
foreach ($registry->getSessionProviders() as $providerClass) {
|
||||||
|
if (!class_exists($providerClass)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$provider = new $providerClass();
|
||||||
|
if ($provider instanceof SessionProvider) {
|
||||||
|
$providers[] = $provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $providers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class RememberMeService
|
|||||||
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
||||||
// Enforce the same tenant policy as interactive login.
|
// Enforce the same tenant policy as interactive login.
|
||||||
$this->forgetCurrentUser();
|
$this->forgetCurrentUser();
|
||||||
|
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,7 +410,16 @@ class UserCustomFieldValueService
|
|||||||
return ['prepared' => $prepared, 'errors' => $errors];
|
return ['prepared' => $prepared, 'errors' => $errors];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function extractAddressBookFilterSpec(array $query, int $currentUserId): array
|
/**
|
||||||
|
* Extract custom field filter specification from a URL query.
|
||||||
|
*
|
||||||
|
* Parses cf_*, cfm_*, and cfd_* query parameters against the user's
|
||||||
|
* visible custom field definitions and returns validated filter data.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $query URL query parameters
|
||||||
|
* @return array{definitions: list<array<string, mixed>>, filters: array<string, array<int, mixed>>, query: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function extractCustomFieldFilterSpec(array $query, int $currentUserId): array
|
||||||
{
|
{
|
||||||
$tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId);
|
$tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId);
|
||||||
$definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds);
|
$definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds);
|
||||||
|
|||||||
@@ -2,39 +2,51 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\Scheduler;
|
namespace MintyPHP\Service\Scheduler;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\Service\Audit\SystemAuditService;
|
use MintyPHP\Service\Audit\SystemAuditService;
|
||||||
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
||||||
use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler;
|
use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler;
|
||||||
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
|
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
|
||||||
use MintyPHP\Service\User\UserLifecycleService;
|
use MintyPHP\Service\User\UserLifecycleService;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class ScheduledJobRegistry
|
class ScheduledJobRegistry
|
||||||
{
|
{
|
||||||
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
|
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
|
||||||
public const SYSTEM_AUDIT_PURGE = 'system_audit_purge';
|
public const SYSTEM_AUDIT_PURGE = 'system_audit_purge';
|
||||||
|
|
||||||
/**
|
/** @var array<string, ScheduledJobHandlerInterface> */
|
||||||
* Maps job_key => handler instance (must implement ScheduledJobHandlerInterface).
|
|
||||||
*
|
|
||||||
* To register a new job:
|
|
||||||
* 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface.
|
|
||||||
* 2. Add a public const for the job key above.
|
|
||||||
* 3. Add one line in __construct(): self::YOUR_JOB_KEY => new YourJobHandler(...),
|
|
||||||
*
|
|
||||||
* No other file needs to be changed.
|
|
||||||
*
|
|
||||||
* @var array<string, ScheduledJobHandlerInterface>
|
|
||||||
*/
|
|
||||||
private array $handlers;
|
private array $handlers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private array $moduleJobs = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
UserLifecycleService $userLifecycleService,
|
UserLifecycleService $userLifecycleService,
|
||||||
SystemAuditService $systemAuditService
|
SystemAuditService $systemAuditService,
|
||||||
|
private readonly AppContainer $container
|
||||||
) {
|
) {
|
||||||
$this->handlers = [
|
$this->handlers = [
|
||||||
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
|
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
|
||||||
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
|
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$this->loadModuleJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +59,9 @@ class ScheduledJobRegistry
|
|||||||
foreach ($this->handlers as $jobKey => $handler) {
|
foreach ($this->handlers as $jobKey => $handler) {
|
||||||
$result[$jobKey] = $handler->definition();
|
$result[$jobKey] = $handler->definition();
|
||||||
}
|
}
|
||||||
|
foreach ($this->moduleJobs as $jobKey => $definition) {
|
||||||
|
$result[$jobKey] = $this->stripHandlerFromDefinition($definition);
|
||||||
|
}
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +74,13 @@ class ScheduledJobRegistry
|
|||||||
if ($jobKey === '') {
|
if ($jobKey === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return isset($this->handlers[$jobKey]) ? $this->handlers[$jobKey]->definition() : null;
|
if (isset($this->handlers[$jobKey])) {
|
||||||
|
return $this->handlers[$jobKey]->definition();
|
||||||
|
}
|
||||||
|
if (isset($this->moduleJobs[$jobKey])) {
|
||||||
|
return $this->stripHandlerFromDefinition($this->moduleJobs[$jobKey]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +94,12 @@ class ScheduledJobRegistry
|
|||||||
*/
|
*/
|
||||||
public function execute(string $jobKey, ?int $actorUserId): array
|
public function execute(string $jobKey, ?int $actorUserId): array
|
||||||
{
|
{
|
||||||
if (!isset($this->handlers[$jobKey])) {
|
if (isset($this->handlers[$jobKey])) {
|
||||||
|
return $this->handlers[$jobKey]->execute($actorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleDefinition = $this->moduleJobs[$jobKey] ?? null;
|
||||||
|
if (!is_array($moduleDefinition)) {
|
||||||
return [
|
return [
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'error_code' => 'job_not_supported',
|
'error_code' => 'job_not_supported',
|
||||||
@@ -81,6 +107,128 @@ class ScheduledJobRegistry
|
|||||||
'result' => [],
|
'result' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $this->handlers[$jobKey]->execute($actorUserId);
|
|
||||||
|
$handlerClass = trim((string) $moduleDefinition['handler']);
|
||||||
|
if ($handlerClass === '') {
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_code' => 'job_handler_missing',
|
||||||
|
'error_message' => 'Scheduler job handler is not configured.',
|
||||||
|
'result' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$handler = $this->resolveHandler($handlerClass);
|
||||||
|
return $handler->execute($actorUserId);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_code' => 'job_handler_resolution_failed',
|
||||||
|
'error_message' => $exception->getMessage(),
|
||||||
|
'result' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadModuleJobs(): void
|
||||||
|
{
|
||||||
|
$registry = $this->resolveModuleRegistry();
|
||||||
|
if (!$registry instanceof ModuleRegistry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($registry->getSchedulerJobs() as $jobDefinition) {
|
||||||
|
$jobKey = trim((string) $jobDefinition['job_key']);
|
||||||
|
if ($jobKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->handlers[$jobKey])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Module scheduler job '{$jobKey}' collides with core scheduler job key."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isset($this->moduleJobs[$jobKey])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Duplicate module scheduler job key '{$jobKey}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->moduleJobs[$jobKey] = [
|
||||||
|
'handler' => trim((string) $jobDefinition['handler']),
|
||||||
|
'label' => trim((string) $jobDefinition['label']),
|
||||||
|
'description' => trim((string) $jobDefinition['description']),
|
||||||
|
'default_enabled' => (int) $jobDefinition['default_enabled'],
|
||||||
|
'default_timezone' => trim((string) $jobDefinition['default_timezone']),
|
||||||
|
'default_schedule_type' => trim((string) $jobDefinition['default_schedule_type']),
|
||||||
|
'default_schedule_interval' => (int) $jobDefinition['default_schedule_interval'],
|
||||||
|
'default_schedule_time' => $jobDefinition['default_schedule_time'],
|
||||||
|
'default_schedule_weekdays_csv' => $jobDefinition['default_schedule_weekdays_csv'],
|
||||||
|
'default_catchup_once' => (int) $jobDefinition['default_catchup_once'],
|
||||||
|
'allowed_schedule_types' => array_values($jobDefinition['allowed_schedule_types']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* handler: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>
|
||||||
|
* } $definition
|
||||||
|
* @return array{
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* default_enabled: int,
|
||||||
|
* default_timezone: string,
|
||||||
|
* default_schedule_type: string,
|
||||||
|
* default_schedule_interval: int,
|
||||||
|
* default_schedule_time: ?string,
|
||||||
|
* default_schedule_weekdays_csv: ?string,
|
||||||
|
* default_catchup_once: int,
|
||||||
|
* allowed_schedule_types: list<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function stripHandlerFromDefinition(array $definition): array
|
||||||
|
{
|
||||||
|
unset($definition['handler']);
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveModuleRegistry(): ?ModuleRegistry
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$registry = $this->container->get(ModuleRegistry::class);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $registry instanceof ModuleRegistry ? $registry : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHandler(string $handlerClass): ScheduledJobHandlerInterface
|
||||||
|
{
|
||||||
|
$handler = null;
|
||||||
|
if ($this->container->has($handlerClass)) {
|
||||||
|
$handler = $this->container->get($handlerClass);
|
||||||
|
} elseif (class_exists($handlerClass)) {
|
||||||
|
$handler = new $handlerClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$handler instanceof ScheduledJobHandlerInterface) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Scheduler job handler '{$handlerClass}' must implement " . ScheduledJobHandlerInterface::class . '.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\Scheduler;
|
namespace MintyPHP\Service\Scheduler;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
|
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
|
||||||
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
|
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
|
||||||
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
|
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
|
||||||
@@ -22,7 +23,8 @@ class SchedulerServicesFactory
|
|||||||
private readonly UserServicesFactory $userServicesFactory,
|
private readonly UserServicesFactory $userServicesFactory,
|
||||||
private readonly AuditServicesFactory $auditServicesFactory,
|
private readonly AuditServicesFactory $auditServicesFactory,
|
||||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||||
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory
|
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory,
|
||||||
|
private readonly AppContainer $appContainer
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,8 @@ class SchedulerServicesFactory
|
|||||||
{
|
{
|
||||||
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry(
|
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry(
|
||||||
$this->getUserLifecycleService(),
|
$this->getUserLifecycleService(),
|
||||||
$this->getSystemAuditService()
|
$this->getSystemAuditService(),
|
||||||
|
$this->appContainer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ class SearchDataService
|
|||||||
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
||||||
$resources = SearchConfig::resources($query, $locale);
|
$resources = SearchConfig::resources($query, $locale);
|
||||||
|
|
||||||
// Merge module-contributed search resources into the pipeline
|
// Merge module-contributed search resources into the pipeline.
|
||||||
|
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
||||||
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
||||||
|
$userIdStr = (string) $userId;
|
||||||
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
||||||
$countSql = $moduleDef['count_sql'];
|
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
||||||
$previewSql = $moduleDef['preview_sql'];
|
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
||||||
$resultSql = $moduleDef['result_sql'];
|
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
||||||
$resources[] = [
|
$resources[] = [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'label' => $moduleDef['label'],
|
'label' => $moduleDef['label'],
|
||||||
@@ -150,12 +152,14 @@ class SearchDataService
|
|||||||
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
||||||
$resources = SearchConfig::resources($query, $locale);
|
$resources = SearchConfig::resources($query, $locale);
|
||||||
|
|
||||||
// Merge module-contributed search resources
|
// Merge module-contributed search resources.
|
||||||
|
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
||||||
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
||||||
|
$userIdStr = (string) $userId;
|
||||||
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
||||||
$countSql = $moduleDef['count_sql'];
|
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
||||||
$previewSql = $moduleDef['preview_sql'];
|
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
||||||
$resultSql = $moduleDef['result_sql'];
|
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
||||||
$resources[] = [
|
$resources[] = [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'label' => $moduleDef['label'],
|
'label' => $moduleDef['label'],
|
||||||
|
|||||||
@@ -498,9 +498,27 @@ function sortByDescription(array &$items): void
|
|||||||
*/
|
*/
|
||||||
function layoutHasAdminPanel(array $layoutAuth): bool
|
function layoutHasAdminPanel(array $layoutAuth): bool
|
||||||
{
|
{
|
||||||
|
// Collect capability keys contributed by modules — these are non-admin
|
||||||
|
// capabilities that should not trigger the admin panel visibility.
|
||||||
|
$moduleCapabilities = [];
|
||||||
|
try {
|
||||||
|
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
|
||||||
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
|
foreach ($registry->getUiSlots() as $contributions) {
|
||||||
|
foreach ($contributions as $contribution) {
|
||||||
|
$perm = is_array($contribution) ? trim((string) ($contribution['permission'] ?? '')) : '';
|
||||||
|
if ($perm !== '') {
|
||||||
|
$moduleCapabilities[$perm] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// fail-open: if module registry is not available, skip module capabilities
|
||||||
|
}
|
||||||
|
|
||||||
$capabilityKeys = array_keys(\MintyPHP\Service\Access\UiCapabilityMap::LAYOUT);
|
$capabilityKeys = array_keys(\MintyPHP\Service\Access\UiCapabilityMap::LAYOUT);
|
||||||
foreach ($capabilityKeys as $key) {
|
foreach ($capabilityKeys as $key) {
|
||||||
if ($key === 'can_view_address_book') {
|
if (isset($moduleCapabilities[$key])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!empty($layoutAuth[$key])) {
|
if (!empty($layoutAuth[$key])) {
|
||||||
@@ -540,43 +558,63 @@ function appNormalizePositiveIntList(mixed $value): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize address-book custom field query keys/values.
|
* Reserved top-level keys in layout navigation context that modules must not override.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $rawQuery
|
* @return list<string>
|
||||||
* @return array<string, string|list<int>>
|
|
||||||
*/
|
*/
|
||||||
function appNormalizeAddressBookCustomFilterQuery(array $rawQuery): array
|
function appLayoutNavReservedKeys(): array
|
||||||
{
|
{
|
||||||
$normalized = [];
|
return [
|
||||||
foreach ($rawQuery as $rawKey => $rawValue) {
|
'hasAdminPanel',
|
||||||
$key = strtolower(trim((string) $rawKey));
|
'moduleSlots',
|
||||||
|
'currentTenant',
|
||||||
|
'availableTenants',
|
||||||
|
'tenantQueryParam',
|
||||||
|
'tenantAvatar',
|
||||||
|
'csrfKey',
|
||||||
|
'csrfToken',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge one module layout-provider payload into layout navigation context.
|
||||||
|
*
|
||||||
|
* Provider keys are contract-validated and must be namespaced (`module.key`).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $layoutNav
|
||||||
|
* @param array<string, mixed> $providerData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function appMergeLayoutNavProviderData(array $layoutNav, array $providerData, string $providerClass): array
|
||||||
|
{
|
||||||
|
$reserved = array_fill_keys(appLayoutNavReservedKeys(), true);
|
||||||
|
|
||||||
|
foreach ($providerData as $rawKey => $value) {
|
||||||
|
$key = trim((string) $rawKey);
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
continue;
|
throw new \RuntimeException(
|
||||||
|
"Layout context provider '{$providerClass}' returned an empty top-level key."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
|
if (isset($reserved[$key])) {
|
||||||
$value = trim((string) $rawValue);
|
throw new \RuntimeException(
|
||||||
if ($value !== '') {
|
"Layout context provider '{$providerClass}' must not override reserved layout key '{$key}'."
|
||||||
$normalized[$key] = $value;
|
);
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
|
if (array_key_exists($key, $layoutNav)) {
|
||||||
$ids = appNormalizePositiveIntList($rawValue);
|
throw new \RuntimeException(
|
||||||
if ($ids) {
|
"Layout context provider '{$providerClass}' collides on existing layout key '{$key}'."
|
||||||
$normalized[$key] = $ids;
|
);
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
|
if (!preg_match('/^[a-z0-9]+[a-z0-9._-]*$/', $key) || !str_contains($key, '.')) {
|
||||||
$value = trim((string) $rawValue);
|
throw new \RuntimeException(
|
||||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
"Layout context provider '{$providerClass}' key '{$key}' must be namespaced (e.g. '<module_id>.data')."
|
||||||
if ($dt && $dt->format('Y-m-d') === $value) {
|
);
|
||||||
$normalized[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
$layoutNav[$key] = $value;
|
||||||
}
|
}
|
||||||
ksort($normalized, SORT_STRING);
|
|
||||||
return $normalized;
|
return $layoutNav;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -628,9 +666,6 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
|
|||||||
'name' => $tenantName,
|
'name' => $tenantName,
|
||||||
'hasAvatar' => $tenantHasAvatar,
|
'hasAvatar' => $tenantHasAvatar,
|
||||||
],
|
],
|
||||||
'bookmarks' => is_array($session['user_bookmarks'] ?? null)
|
|
||||||
? $session['user_bookmarks']
|
|
||||||
: ['groups' => [], 'ungrouped' => []],
|
|
||||||
'csrfKey' => $csrfKey,
|
'csrfKey' => $csrfKey,
|
||||||
'csrfToken' => $csrfToken,
|
'csrfToken' => $csrfToken,
|
||||||
];
|
];
|
||||||
@@ -641,19 +676,30 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
|
|||||||
try {
|
try {
|
||||||
/** @var \MintyPHP\App\Module\ModuleRegistry $moduleRegistry */
|
/** @var \MintyPHP\App\Module\ModuleRegistry $moduleRegistry */
|
||||||
$moduleRegistry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
$moduleRegistry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
/** @var \MintyPHP\App\AppContainer $container */
|
|
||||||
$container = $GLOBALS['minty_app_container'];
|
|
||||||
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
|
|
||||||
if (!class_exists($providerClass)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$provider = new $providerClass();
|
|
||||||
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
|
|
||||||
$layoutNav = array_merge($layoutNav, $provider->provide($session, $container));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// fail-open: if module registry is not available, skip module providers
|
// fail-open: if module registry is not available, skip module providers
|
||||||
|
return $layoutNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $GLOBALS['minty_app_container'] ?? null;
|
||||||
|
if (!$container instanceof \MintyPHP\App\AppContainer) {
|
||||||
|
return $layoutNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
|
||||||
|
if (!class_exists($providerClass)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$provider = new $providerClass();
|
||||||
|
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
|
||||||
|
$providerData = $provider->provide($session, $container);
|
||||||
|
if (!is_array($providerData)) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Layout context provider '{$providerClass}' must return an array."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$layoutNav = appMergeLayoutNavProviderData($layoutNav, $providerData, $providerClass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $layoutNav;
|
return $layoutNav;
|
||||||
|
|||||||
@@ -482,6 +482,24 @@ function appStylePathsForGroups(array $groupNames): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge module-contributed style groups (same shape as config/assets.php groups).
|
||||||
|
try {
|
||||||
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||||
|
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
|
||||||
|
foreach ($registry->getAssetGroups() as $groupName => $paths) {
|
||||||
|
if (!is_string($groupName) || $groupName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_array($paths) || isset($groups[$groupName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$groups[$groupName] = $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// fail-open: no module registry in this runtime context.
|
||||||
|
}
|
||||||
|
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
// Used as a fast set to keep insertion order while removing duplicates.
|
// Used as a fast set to keep insertion order while removing duplicates.
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-department-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-department-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<button type="button" data-tab="visibility"><?php e(t('Visibility')); ?></button>
|
<button type="button" data-tab="visibility"><?php e(t('Visibility')); ?></button>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ $keyReadonlyAttr = ($isReadOnly || !empty($values['is_system'])) ? 'readonly' :
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-permission-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-permission-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<?php if ($roles): ?>
|
<?php if ($roles): ?>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-role-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-role-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<button type="button" data-tab="permissions"><?php e(t('Permissions')); ?></button>
|
<button type="button" data-tab="permissions"><?php e(t('Permissions')); ?></button>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
|
|||||||
<form id="settings-form" method="post" data-details-storage="settings-main-details-v1" data-standard-detail-form="1">
|
<form id="settings-form" method="post" data-details-storage="settings-main-details-v1" data-standard-detail-form="1">
|
||||||
<input type="hidden" name="settings_submit" value="1">
|
<input type="hidden" name="settings_submit" value="1">
|
||||||
|
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-settings-tabs-v1">
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-settings-tabs-v1">
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="master-data" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="master-data" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<button type="button" data-tab="appearance"><?php e(t('Appearance')); ?></button>
|
<button type="button" data-tab="appearance"><?php e(t('Appearance')); ?></button>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ require templatePath('partials/app-breadcrumb.phtml');
|
|||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="app-dashboard">
|
<div class="app-dashboard">
|
||||||
<div class="app-tabs" data-tabs id="stats-tabs">
|
<div class="app-tabs" data-tabs data-app-component="tabs" id="stats-tabs">
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button data-tab="users" data-tab-default class="transparent tab-button">
|
<button data-tab="users" data-tab-default class="transparent tab-button">
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ $openOverrideCard = $detailsOpenAll;
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-tenant-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-tenant-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<?php if ($showCustomFieldsTab): ?>
|
<?php if ($showCustomFieldsTab): ?>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ foreach ($tenants as $tenant) {
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-details-storage="admin-user-form-details-v1" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-details-storage="admin-user-form-details-v1" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-user-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-user-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="profile" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="profile" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
<?php if ($showOrganizationTab): ?>
|
<?php if ($showOrganizationTab): ?>
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ $canEditAssignments = (bool) ($capabilities['can_edit_assignments'] ?? false);
|
|||||||
$canManageTenants = (bool) ($capabilities['can_manage_tenants'] ?? false);
|
$canManageTenants = (bool) ($capabilities['can_manage_tenants'] ?? false);
|
||||||
$canEditCustomFieldValues = (bool) ($capabilities['can_edit_custom_field_values'] ?? false);
|
$canEditCustomFieldValues = (bool) ($capabilities['can_edit_custom_field_values'] ?? false);
|
||||||
$canManageApiTokens = (bool) ($capabilities['can_manage_api_tokens'] ?? false);
|
$canManageApiTokens = (bool) ($capabilities['can_manage_api_tokens'] ?? false);
|
||||||
$canViewAddressBook = (bool) ($capabilities['can_view_address_book'] ?? false);
|
|
||||||
$canViewUserMeta = (bool) ($capabilities['can_view_user_meta'] ?? false);
|
$canViewUserMeta = (bool) ($capabilities['can_view_user_meta'] ?? false);
|
||||||
$canViewUserAudit = (bool) ($capabilities['can_view_user_audit'] ?? false);
|
$canViewUserAudit = (bool) ($capabilities['can_view_user_audit'] ?? false);
|
||||||
$canAccessPdf = (bool) ($capabilities['can_access_pdf'] ?? false);
|
$canAccessPdf = (bool) ($capabilities['can_access_pdf'] ?? false);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ $currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
|||||||
$isOwnAccount = $currentUserId > 0 && $currentUserId === (int) ($values['id'] ?? 0);
|
$isOwnAccount = $currentUserId > 0 && $currentUserId === (int) ($values['id'] ?? 0);
|
||||||
$titleText = $isOwnAccount ? t('My account') : t('Edit user');
|
$titleText = $isOwnAccount ? t('My account') : t('Edit user');
|
||||||
$canViewUsers = !empty($canViewUsers);
|
$canViewUsers = !empty($canViewUsers);
|
||||||
$canViewAddressBook = !empty($canViewAddressBook);
|
|
||||||
$canViewUserMeta = !empty($canViewUserMeta);
|
$canViewUserMeta = !empty($canViewUserMeta);
|
||||||
$canViewUserAudit = !empty($canViewUserAudit);
|
$canViewUserAudit = !empty($canViewUserAudit);
|
||||||
$canAccessPdf = !empty($canAccessPdf);
|
$canAccessPdf = !empty($canAccessPdf);
|
||||||
@@ -58,11 +57,56 @@ if ($lastLoginAt !== '') {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
$asideActions = [];
|
$asideActions = [];
|
||||||
if ($canViewAddressBook) {
|
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||||
|
$moduleAsideActionSlots = is_array($moduleSlots['user.edit.aside_action'] ?? null) ? $moduleSlots['user.edit.aside_action'] : [];
|
||||||
|
$slotContextMap = [
|
||||||
|
'{user_id}' => (string) ($values['id'] ?? ''),
|
||||||
|
'{user_uuid}' => (string) ($values['uuid'] ?? ''),
|
||||||
|
];
|
||||||
|
foreach ($moduleAsideActionSlots as $slotAction) {
|
||||||
|
if (!is_array($slotAction)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotPermission = trim((string) ($slotAction['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotLabel = trim((string) ($slotAction['label'] ?? ''));
|
||||||
|
if ($slotLabel === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotType = trim((string) ($slotAction['type'] ?? 'link'));
|
||||||
|
if ($slotType === 'form') {
|
||||||
|
$slotFormAction = trim((string) ($slotAction['action'] ?? ''));
|
||||||
|
if ($slotFormAction === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotFormAction = strtr($slotFormAction, $slotContextMap);
|
||||||
|
if (!str_starts_with($slotFormAction, '/') && !str_starts_with($slotFormAction, 'http://') && !str_starts_with($slotFormAction, 'https://')) {
|
||||||
|
$slotFormAction = lurl($slotFormAction);
|
||||||
|
}
|
||||||
|
$asideActions[] = [
|
||||||
|
'type' => 'form',
|
||||||
|
'method' => strtoupper(trim((string) ($slotAction['method'] ?? 'POST'))),
|
||||||
|
'action' => $slotFormAction,
|
||||||
|
'label' => t($slotLabel),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotHref = trim((string) ($slotAction['href'] ?? ''));
|
||||||
|
if ($slotHref === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotHref = strtr($slotHref, $slotContextMap);
|
||||||
|
if (!str_starts_with($slotHref, '/') && !str_starts_with($slotHref, 'http://') && !str_starts_with($slotHref, 'https://')) {
|
||||||
|
$slotHref = lurl($slotHref);
|
||||||
|
}
|
||||||
$asideActions[] = [
|
$asideActions[] = [
|
||||||
'type' => 'link',
|
'type' => 'link',
|
||||||
'href' => lurl('address-book/view/' . ($values['uuid'] ?? '')),
|
'href' => $slotHref,
|
||||||
'label' => t('View in address book'),
|
'label' => t($slotLabel),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if ($canEditUser) {
|
if ($canEditUser) {
|
||||||
|
|||||||
@@ -77,12 +77,8 @@ if (isset($session['user']) && is_array($session['user'])) {
|
|||||||
$sessionStore->set('user', $userSession);
|
$sessionStore->set('user', $userSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update session with full tenant data
|
// Reload full tenant + module session context in one place.
|
||||||
$sessionStore->set('current_tenant', $result['tenant'] ?? null);
|
app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($userId);
|
||||||
|
|
||||||
// Reload available tenants and departments
|
|
||||||
$sessionStore->set('available_tenants', $userTenantContextService->getAvailableTenants($userId));
|
|
||||||
$sessionStore->set('available_departments_by_tenant', $userTenantContextService->getAvailableDepartmentsByTenant($userId));
|
|
||||||
|
|
||||||
if (Request::wantsJson()) {
|
if (Request::wantsJson()) {
|
||||||
Router::json([
|
Router::json([
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
|
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
|
||||||
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
|
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script type="module" src="<?php e(assetVersion('js/components/app-page-editor.js')); ?>"></script>
|
<script type="application/json" id="page-config-page-editor"><?php gridJsonForJs([
|
||||||
|
'selector' => '[data-page-editor]',
|
||||||
|
'holderSelector' => '[data-editor-holder]',
|
||||||
|
'contentFieldSelector' => '#page-content',
|
||||||
|
'toggleSelector' => '[data-editor-toggle]',
|
||||||
|
'saveSelector' => '[data-editor-save]',
|
||||||
|
]); ?></script>
|
||||||
|
<script type="module" src="<?php e(assetVersion('js/pages/app-page-editor-entry.js')); ?>"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php if ($canEdit): ?>
|
<?php if ($canEdit): ?>
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
|
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
|
||||||
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
|
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script type="module" src="<?php e(assetVersion('js/components/app-page-editor.js')); ?>"></script>
|
<script type="application/json" id="page-config-page-editor"><?php gridJsonForJs([
|
||||||
|
'selector' => '[data-page-editor]',
|
||||||
|
'holderSelector' => '[data-editor-holder]',
|
||||||
|
'contentFieldSelector' => '#page-content',
|
||||||
|
'toggleSelector' => '[data-editor-toggle]',
|
||||||
|
'saveSelector' => '[data-editor-save]',
|
||||||
|
]); ?></script>
|
||||||
|
<script type="module" src="<?php e(assetVersion('js/pages/app-page-editor-entry.js')); ?>"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php if ($canEdit): ?>
|
<?php if ($canEdit): ?>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ parameters:
|
|||||||
paths:
|
paths:
|
||||||
- config
|
- config
|
||||||
- lib
|
- lib
|
||||||
|
- modules
|
||||||
- pages
|
- pages
|
||||||
- tests
|
- tests
|
||||||
fileExtensions:
|
fileExtensions:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="CoreCore">
|
<testsuite name="CoreCore">
|
||||||
<directory>tests</directory>
|
<directory>tests</directory>
|
||||||
|
<directory>modules/*/tests</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ $csrfToken = (string) ($_SESSION[$csrfKey] ?? '');
|
|||||||
$isLoggedIn = !empty($user['id']);
|
$isLoggedIn = !empty($user['id']);
|
||||||
$sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30);
|
$sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30);
|
||||||
$sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60;
|
$sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60;
|
||||||
|
$sessionPingUrl = lurl('admin/session-ping/data');
|
||||||
|
$sessionLogoutUrl = lurl('logout');
|
||||||
$telemetryEnabled = frontendTelemetryEnabled();
|
$telemetryEnabled = frontendTelemetryEnabled();
|
||||||
$telemetrySampleRate = frontendTelemetrySampleRate();
|
$telemetrySampleRate = frontendTelemetrySampleRate();
|
||||||
$telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents());
|
$telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents());
|
||||||
@@ -20,6 +22,118 @@ $telemetryUrl = lurl('admin/frontend-telemetry/ingest');
|
|||||||
$pageRequestId = RequestContext::id();
|
$pageRequestId = RequestContext::id();
|
||||||
$viewAuth = is_array($viewAuth ?? null) ? $viewAuth : [];
|
$viewAuth = is_array($viewAuth ?? null) ? $viewAuth : [];
|
||||||
$viewAuth['layout'] = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
$viewAuth['layout'] = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||||
|
$layoutAuth = $viewAuth['layout'];
|
||||||
|
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||||
|
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||||
|
$moduleHeadStyleSlots = is_array($moduleSlots['layout.head_style'] ?? null) ? $moduleSlots['layout.head_style'] : [];
|
||||||
|
$moduleBodyEndTemplateSlots = is_array($moduleSlots['layout.body_end_template'] ?? null) ? $moduleSlots['layout.body_end_template'] : [];
|
||||||
|
$moduleRuntimeComponentSlots = is_array($moduleSlots['runtime.component'] ?? null) ? $moduleSlots['runtime.component'] : [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $value
|
||||||
|
*/
|
||||||
|
$isAssocArray = static function (array $value): bool {
|
||||||
|
if ($value === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return array_keys($value) !== range(0, count($value) - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
* @param array<string, mixed> $source
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
$strictMergeConfig = static function (array $target, array $source, string $context, callable $isAssocArray) use (&$strictMergeConfig): array {
|
||||||
|
foreach ($source as $key => $sourceValue) {
|
||||||
|
if (!is_string($key) || trim($key) === '') {
|
||||||
|
throw new RuntimeException("Invalid default_config key in {$context}.");
|
||||||
|
}
|
||||||
|
if (!array_key_exists($key, $target)) {
|
||||||
|
$target[$key] = $sourceValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetValue = $target[$key];
|
||||||
|
if (is_array($targetValue) && is_array($sourceValue) && $isAssocArray($targetValue) && $isAssocArray($sourceValue)) {
|
||||||
|
$target[$key] = $strictMergeConfig($targetValue, $sourceValue, $context . '.' . $key, $isAssocArray);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetValue !== $sourceValue) {
|
||||||
|
throw new RuntimeException("default_config collision at {$context}.{$key}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $root
|
||||||
|
* @param array<string, mixed> $value
|
||||||
|
*/
|
||||||
|
$mergeConfigAtPath = static function (array &$root, string $path, array $value, string $context, callable $strictMergeConfig): void {
|
||||||
|
$segments = array_values(array_filter(array_map('trim', explode('.', $path)), static fn (string $item): bool => $item !== ''));
|
||||||
|
if ($segments === []) {
|
||||||
|
throw new RuntimeException("Invalid config_path in {$context}: '{$path}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = &$root;
|
||||||
|
$fullPath = '';
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $segment)) {
|
||||||
|
throw new RuntimeException("Invalid config_path segment '{$segment}' in {$context}.");
|
||||||
|
}
|
||||||
|
$fullPath = $fullPath === '' ? $segment : $fullPath . '.' . $segment;
|
||||||
|
if (!array_key_exists($segment, $cursor)) {
|
||||||
|
$cursor[$segment] = [];
|
||||||
|
}
|
||||||
|
if (!is_array($cursor[$segment])) {
|
||||||
|
throw new RuntimeException("Config path '{$fullPath}' in {$context} is not an object.");
|
||||||
|
}
|
||||||
|
$cursor = &$cursor[$segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $strictMergeConfig(
|
||||||
|
is_array($cursor) ? $cursor : [],
|
||||||
|
$value,
|
||||||
|
$context,
|
||||||
|
static fn (array $raw): bool => $raw !== [] && array_keys($raw) !== range(0, count($raw) - 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$normalizeConfigPathSegment = static function (string $raw): string {
|
||||||
|
$segment = preg_replace('/[^a-zA-Z0-9_]+/', '_', strtolower(trim($raw))) ?? '';
|
||||||
|
$segment = trim($segment, '_');
|
||||||
|
if ($segment === '') {
|
||||||
|
return 'item';
|
||||||
|
}
|
||||||
|
if (preg_match('/^[0-9]/', $segment)) {
|
||||||
|
return 'n_' . $segment;
|
||||||
|
}
|
||||||
|
return $segment;
|
||||||
|
};
|
||||||
|
|
||||||
|
$moduleHeadStylePaths = [];
|
||||||
|
$moduleHeadSeen = [];
|
||||||
|
foreach ($moduleHeadStyleSlots as $slot) {
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = ltrim(trim((string) ($slot['path'] ?? '')), '/');
|
||||||
|
if ($path === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($moduleHeadSeen[$path])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$moduleHeadSeen[$path] = true;
|
||||||
|
$moduleHeadStylePaths[] = $path;
|
||||||
|
}
|
||||||
$pageTitle = $defaultTitle;
|
$pageTitle = $defaultTitle;
|
||||||
ob_start();
|
ob_start();
|
||||||
Buffer::get('title');
|
Buffer::get('title');
|
||||||
@@ -27,6 +141,146 @@ $bufferTitle = trim((string) ob_get_clean());
|
|||||||
if ($bufferTitle !== '') {
|
if ($bufferTitle !== '') {
|
||||||
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
||||||
}
|
}
|
||||||
|
$componentPageConfig = [
|
||||||
|
'components' => [
|
||||||
|
'confirmActions' => [],
|
||||||
|
'flashAutoDismiss' => [
|
||||||
|
'selector' => '.notice[data-flash-timeout]',
|
||||||
|
],
|
||||||
|
'passwordHints' => [],
|
||||||
|
'copyBadge' => [],
|
||||||
|
'multiSelect' => [
|
||||||
|
'selector' => '[data-multi-select]',
|
||||||
|
],
|
||||||
|
'navHistory' => [],
|
||||||
|
'detailsState' => [
|
||||||
|
'selector' => '[data-details-storage]',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'details-open',
|
||||||
|
],
|
||||||
|
'settingsTelemetry' => [],
|
||||||
|
'autoSubmit' => [
|
||||||
|
'selector' => '[data-auto-submit]',
|
||||||
|
],
|
||||||
|
'sidebarToggle' => [
|
||||||
|
'buttonSelector' => '[data-sidebar-toggle]',
|
||||||
|
'asideSelector' => '#app-sidebar',
|
||||||
|
'containerSelector' => '.app-container',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'sidebar',
|
||||||
|
'collapsedStorageKey' => 'sidebar-collapsed',
|
||||||
|
'hiddenStorageKey' => 'sidebar-hidden',
|
||||||
|
],
|
||||||
|
'asidePanels' => [
|
||||||
|
'barSelector' => '.aside-icon-bar',
|
||||||
|
'panelSelector' => '.app-sidebar-panel',
|
||||||
|
'titleSelector' => '.app-sidebar-title',
|
||||||
|
'toolsSelector' => '[data-aside-tools]',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'aside-panels',
|
||||||
|
'detailsStorageScope' => 'aside-details',
|
||||||
|
],
|
||||||
|
'detailsAsideToggle' => [
|
||||||
|
'buttonSelector' => '#toggle-main-content-aside',
|
||||||
|
'asideSelector' => '#app-details-aside-section',
|
||||||
|
'containerSelector' => '.app-details-container',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'details-aside',
|
||||||
|
],
|
||||||
|
'themeControls' => [
|
||||||
|
'menuSelector' => '[data-theme-menu]',
|
||||||
|
'toggleSelector' => '[data-theme-toggle]',
|
||||||
|
],
|
||||||
|
'contrastToggle' => [
|
||||||
|
'selector' => '[data-contrast-toggle]',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'contrast',
|
||||||
|
'storageKey' => 'contrast-mode',
|
||||||
|
],
|
||||||
|
'tenantSwitcher' => [
|
||||||
|
'rootSelector' => '[data-tenant-switcher]',
|
||||||
|
'linkSelector' => '[data-switch-tenant]',
|
||||||
|
],
|
||||||
|
'colorDefaultToggle' => [],
|
||||||
|
'customFieldOptionsToggle' => [],
|
||||||
|
'tenantSsoToggle' => [],
|
||||||
|
'standardDetailPage' => [],
|
||||||
|
'globalSearch' => [
|
||||||
|
'inputSelector' => '#side-search',
|
||||||
|
'resultsSelector' => '[data-global-search-results]',
|
||||||
|
'appBase' => localeBase(),
|
||||||
|
],
|
||||||
|
'tabs' => [
|
||||||
|
'selector' => '[data-app-component="tabs"]',
|
||||||
|
'storageNamespace' => 'app-ui',
|
||||||
|
'storageVersion' => 'v1',
|
||||||
|
'storageScope' => 'tabs',
|
||||||
|
],
|
||||||
|
'sessionWarning' => [
|
||||||
|
'enabled' => $isLoggedIn,
|
||||||
|
'selector' => '[data-app-session-warning-dialog]',
|
||||||
|
'idleSeconds' => $sessionIdleSeconds,
|
||||||
|
'pingUrl' => $sessionPingUrl,
|
||||||
|
'logoutUrl' => $sessionLogoutUrl,
|
||||||
|
'csrfKey' => $csrfKey,
|
||||||
|
'csrfToken' => $csrfToken,
|
||||||
|
],
|
||||||
|
'fslightboxRefresh' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$moduleRuntimeComponents = [];
|
||||||
|
foreach ($moduleRuntimeComponentSlots as $slot) {
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotKey = trim((string) ($slot['key'] ?? ''));
|
||||||
|
$moduleId = trim((string) ($slot['module_id'] ?? ''));
|
||||||
|
$scriptPath = ltrim(trim((string) ($slot['script'] ?? '')), '/');
|
||||||
|
if ($slotKey === '' || $moduleId === '' || $scriptPath === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phase = strtolower(trim((string) ($slot['phase'] ?? 'late')));
|
||||||
|
if ($phase !== 'early' && $phase !== 'default' && $phase !== 'late') {
|
||||||
|
throw new RuntimeException("Invalid runtime.component phase '{$phase}' for slot '{$slotKey}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = trim((string) ($slot['config_path'] ?? ''));
|
||||||
|
if ($configPath === '') {
|
||||||
|
$configPath = 'components.modules.' . $normalizeConfigPathSegment($moduleId) . '.' . $normalizeConfigPathSegment($slotKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultConfig = is_array($slot['default_config'] ?? null) ? $slot['default_config'] : [];
|
||||||
|
if ($defaultConfig !== []) {
|
||||||
|
$mergeConfigAtPath($componentPageConfig, $configPath, $defaultConfig, "runtime.component '{$slotKey}'", $strictMergeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleRuntimeComponents[] = [
|
||||||
|
'key' => $slotKey,
|
||||||
|
'moduleId' => $moduleId,
|
||||||
|
'componentName' => $moduleId . '.' . $slotKey,
|
||||||
|
'script' => assetVersion($scriptPath),
|
||||||
|
'export' => trim((string) ($slot['export'] ?? '')),
|
||||||
|
'selector' => trim((string) ($slot['selector'] ?? '')),
|
||||||
|
'scope' => trim((string) ($slot['scope'] ?? '')),
|
||||||
|
'configPath' => $configPath,
|
||||||
|
'phase' => $phase,
|
||||||
|
'order' => (int) ($slot['order'] ?? 100),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -43,8 +297,8 @@ if ($bufferTitle !== '') {
|
|||||||
data-csrf-token="<?php e($csrfToken); ?>"
|
data-csrf-token="<?php e($csrfToken); ?>"
|
||||||
<?php if ($isLoggedIn): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>"
|
data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>"
|
||||||
data-session-ping-url="<?php e(lurl('admin/session-ping/data')); ?>"
|
data-session-ping-url="<?php e($sessionPingUrl); ?>"
|
||||||
data-session-logout-url="<?php e(lurl('logout')); ?>"
|
data-session-logout-url="<?php e($sessionLogoutUrl); ?>"
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
class="no-js"
|
class="no-js"
|
||||||
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>
|
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>
|
||||||
@@ -62,6 +316,7 @@ if ($bufferTitle !== '') {
|
|||||||
<link rel="apple-touch-icon" href="<?php e(appFaviconUrl('apple-touch-icon.png')); ?>">
|
<link rel="apple-touch-icon" href="<?php e(appFaviconUrl('apple-touch-icon.png')); ?>">
|
||||||
<link rel="manifest" href="<?php e(asset('favicon/site.webmanifest')); ?>">
|
<link rel="manifest" href="<?php e(asset('favicon/site.webmanifest')); ?>">
|
||||||
<?php renderTemplateStyles('default'); ?>
|
<?php renderTemplateStyles('default'); ?>
|
||||||
|
<?php renderStylePaths($moduleHeadStylePaths); ?>
|
||||||
<?php renderPageStyles(); ?>
|
<?php renderPageStyles(); ?>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -86,10 +341,24 @@ if ($bufferTitle !== '') {
|
|||||||
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
||||||
<?php if ($isLoggedIn): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
||||||
<?php require __DIR__ . '/partials/app-bookmark-dialog.phtml'; ?>
|
|
||||||
<script type="module" src="<?php e(assetVersion('js/components/app-session-warning.js')); ?>"></script>
|
|
||||||
<script type="module" src="<?php e(assetVersion('js/components/app-bookmark-init.js')); ?>"></script>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>
|
||||||
|
<?php
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotTemplate = trim((string) ($slot['template'] ?? ''));
|
||||||
|
if ($slotTemplate === '' || !is_file($slotTemplate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
include $slotTemplate;
|
||||||
|
?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<script type="application/json" id="page-config-app-components"><?php gridJsonForJs($componentPageConfig); ?></script>
|
||||||
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ $bufferTitle = trim((string) ob_get_clean());
|
|||||||
if ($bufferTitle !== '') {
|
if ($bufferTitle !== '') {
|
||||||
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
||||||
}
|
}
|
||||||
|
$loginComponentConfig = [
|
||||||
|
'components' => [
|
||||||
|
'flashAutoDismiss' => [
|
||||||
|
'selector' => '.notice[data-flash-timeout]',
|
||||||
|
],
|
||||||
|
'passwordHints' => [
|
||||||
|
'selector' => '[data-password-hints]',
|
||||||
|
],
|
||||||
|
'passwordToggle' => [
|
||||||
|
'selector' => 'input[type="password"]',
|
||||||
|
],
|
||||||
|
'loginErrorFocus' => [
|
||||||
|
'selector' => '.notice[data-variant="error"][role="alert"][tabindex="-1"]',
|
||||||
|
],
|
||||||
|
'loginSubmitLock' => [
|
||||||
|
'selector' => 'form[data-login-submit-lock="1"]',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -48,6 +67,7 @@ if ($bufferTitle !== '') {
|
|||||||
</main>
|
</main>
|
||||||
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
|
||||||
<div id="gradient"></div>
|
<div id="gradient"></div>
|
||||||
|
<script type="application/json" id="page-config-login-components"><?php gridJsonForJs($loginComponentConfig); ?></script>
|
||||||
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
|
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ $timeouts = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<div class="flash-stack" role="status" aria-live="polite">
|
<div class="flash-stack" data-app-component="flash-auto-dismiss" role="status" aria-live="polite">
|
||||||
<?php foreach ($messages as $message) : ?>
|
<?php foreach ($messages as $message) : ?>
|
||||||
<?php $type = $message['type'] ?? 'info'; ?>
|
<?php $type = $message['type'] ?? 'info'; ?>
|
||||||
<?php $timeout = $timeouts[$type] ?? 5000; ?>
|
<?php $timeout = $timeouts[$type] ?? 5000; ?>
|
||||||
|
|||||||
@@ -51,14 +51,6 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth);
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<li>
|
|
||||||
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
|
|
||||||
aria-selected="false" aria-controls="aside-panel-bookmarks"
|
|
||||||
aria-label="<?php e(t('Bookmarks')); ?>"
|
|
||||||
data-tooltip="<?php e(t('Bookmarks')); ?>" data-tooltip-pos="right">
|
|
||||||
<i class="bi bi-bookmark-star"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<?php if ($hasAdminPanel): ?>
|
<?php if ($hasAdminPanel): ?>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"
|
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use MintyPHP\Service\Docs\DocsCatalogService;
|
use MintyPHP\Service\Docs\DocsCatalogService;
|
||||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
|
||||||
|
|
||||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||||
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
|
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
|
||||||
@@ -255,6 +254,9 @@ $tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
|
|||||||
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
|
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
|
||||||
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
|
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
|
||||||
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||||
|
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||||
|
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
||||||
|
$moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $moduleSlots['search.resource_item'] : [];
|
||||||
?>
|
?>
|
||||||
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
|
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
|
||||||
<?php if ($currentTenant): ?>
|
<?php if ($currentTenant): ?>
|
||||||
@@ -302,8 +304,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
// ── Module-contributed aside panels (aside.tab_panel slot) ──
|
// ── Module-contributed aside panels (aside.tab_panel slot) ──
|
||||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
|
||||||
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
|
||||||
foreach ($modulePanelSlots as $panelSlot):
|
foreach ($modulePanelSlots as $panelSlot):
|
||||||
if (!is_array($panelSlot)) { continue; }
|
if (!is_array($panelSlot)) { continue; }
|
||||||
$panelKey = $panelSlot['key'] ?? '';
|
$panelKey = $panelSlot['key'] ?? '';
|
||||||
@@ -357,17 +357,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
|||||||
<ul class="app-search-preview" data-search-preview></ul>
|
<ul class="app-search-preview" data-search-preview></ul>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($canViewAddressBook): ?>
|
|
||||||
<?php $addressBookSearch = navActive('address-book', true); ?>
|
|
||||||
<li data-search-key="address-book" data-search-base="<?php e(lurl('address-book')); ?>">
|
|
||||||
<a href="<?php e(lurl('address-book')); ?>" class="<?php e($addressBookSearch['class'] ?? ''); ?>"
|
|
||||||
<?php echo $addressBookSearch['aria'] ?? ''; ?>>
|
|
||||||
<span><?php e(t('Address book')); ?></span>
|
|
||||||
<span class="badge" data-search-count>0</span>
|
|
||||||
</a>
|
|
||||||
<ul class="app-search-preview" data-search-preview></ul>
|
|
||||||
</li>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($canViewTenants): ?>
|
<?php if ($canViewTenants): ?>
|
||||||
<?php $tenantsSearch = navActive('admin/tenants', true); ?>
|
<?php $tenantsSearch = navActive('admin/tenants', true); ?>
|
||||||
<li data-search-key="tenants" data-search-base="<?php e(lurl('admin/tenants')); ?>">
|
<li data-search-key="tenants" data-search-base="<?php e(lurl('admin/tenants')); ?>">
|
||||||
@@ -482,246 +471,49 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
|||||||
<ul class="app-search-preview" data-search-preview></ul>
|
<ul class="app-search-preview" data-search-preview></ul>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
$coreSearchKeys = [
|
||||||
|
'users' => true,
|
||||||
|
'tenants' => true,
|
||||||
|
'departments' => true,
|
||||||
|
'roles' => true,
|
||||||
|
'permissions' => true,
|
||||||
|
'scheduled-jobs' => true,
|
||||||
|
'pages' => true,
|
||||||
|
'docs' => true,
|
||||||
|
'hotkeys' => true,
|
||||||
|
'mail-log' => true,
|
||||||
|
'api-audit' => true,
|
||||||
|
'system-audit' => true,
|
||||||
|
];
|
||||||
|
foreach ($moduleSearchSlots as $searchSlot):
|
||||||
|
if (!is_array($searchSlot)) { continue; }
|
||||||
|
$slotKey = trim((string) ($searchSlot['key'] ?? ''));
|
||||||
|
if ($slotKey === '' || isset($coreSearchKeys[$slotKey])) { continue; }
|
||||||
|
$slotPermission = trim((string) ($searchSlot['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; }
|
||||||
|
$slotLabel = trim((string) ($searchSlot['label'] ?? ''));
|
||||||
|
if ($slotLabel === '') { continue; }
|
||||||
|
$slotBase = trim((string) ($searchSlot['base_url'] ?? ''));
|
||||||
|
if ($slotBase === '') { continue; }
|
||||||
|
$slotHref = $slotBase;
|
||||||
|
$slotActive = ['class' => '', 'aria' => ''];
|
||||||
|
if (!str_starts_with($slotBase, '/') && !str_starts_with($slotBase, 'http://') && !str_starts_with($slotBase, 'https://')) {
|
||||||
|
$slotHref = lurl($slotBase);
|
||||||
|
$slotActive = navActive($slotBase, true);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<li data-search-key="<?php e($slotKey); ?>" data-search-base="<?php e($slotHref); ?>">
|
||||||
|
<a href="<?php e($slotHref); ?>" class="<?php e($slotActive['class'] ?? ''); ?>"
|
||||||
|
<?php echo $slotActive['aria'] ?? ''; ?>>
|
||||||
|
<span><?php e(t($slotLabel)); ?></span>
|
||||||
|
<span class="badge" data-search-count>0</span>
|
||||||
|
</a>
|
||||||
|
<ul class="app-search-preview" data-search-preview></ul>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
|
||||||
$bookmarkData = is_array($layoutNav['bookmarks'] ?? null) ? $layoutNav['bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
|
||||||
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
|
|
||||||
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
|
|
||||||
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
|
||||||
?>
|
|
||||||
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-app-component="bookmark-panel" data-aside-panel="bookmarks"
|
|
||||||
data-aside-title="<?php e(t('Bookmarks')); ?>"
|
|
||||||
data-aside-details-storage="aside-bookmarks-groups-v1"
|
|
||||||
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
|
|
||||||
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
|
|
||||||
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
|
|
||||||
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
|
|
||||||
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
|
|
||||||
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
|
|
||||||
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
|
|
||||||
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
|
|
||||||
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
|
|
||||||
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
|
|
||||||
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
|
|
||||||
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
|
|
||||||
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
|
|
||||||
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
|
|
||||||
data-bookmark-label-delete="<?php e(t('Delete')); ?>"
|
|
||||||
role="tabpanel" aria-labelledby="aside-tab-bookmarks" hidden>
|
|
||||||
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
|
|
||||||
<?php
|
|
||||||
$emptyState = [
|
|
||||||
'message' => t('No bookmarks yet'),
|
|
||||||
'size' => 'compact',
|
|
||||||
'align' => 'center',
|
|
||||||
];
|
|
||||||
require templatePath('partials/app-empty-state.phtml');
|
|
||||||
?>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php
|
|
||||||
$bookmarkRootItems = [];
|
|
||||||
foreach ($bookmarkUngrouped as $bm) {
|
|
||||||
$bmId = (int) ($bm['id'] ?? 0);
|
|
||||||
if ($bmId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$bookmarkRootItems[] = [
|
|
||||||
'type' => 'bookmark',
|
|
||||||
'id' => $bmId,
|
|
||||||
'sort_order' => (int) ($bm['sort_order'] ?? 0),
|
|
||||||
'bookmark' => $bm,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
foreach ($bookmarkGroups as $group) {
|
|
||||||
$gId = (int) ($group['id'] ?? 0);
|
|
||||||
if ($gId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$bookmarkRootItems[] = [
|
|
||||||
'type' => 'group',
|
|
||||||
'id' => $gId,
|
|
||||||
'sort_order' => (int) ($group['sort_order'] ?? 0),
|
|
||||||
'group' => $group,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
usort($bookmarkRootItems, static function (array $a, array $b): int {
|
|
||||||
$sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
|
||||||
if ($sortCmp !== 0) {
|
|
||||||
return $sortCmp;
|
|
||||||
}
|
|
||||||
$typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
|
|
||||||
if ($typeCmp !== 0) {
|
|
||||||
return $typeCmp;
|
|
||||||
}
|
|
||||||
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
|
||||||
});
|
|
||||||
$rootCount = count($bookmarkRootItems);
|
|
||||||
?>
|
|
||||||
<ul data-bookmark-root-list>
|
|
||||||
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
|
|
||||||
<?php
|
|
||||||
$rootType = (string) ($rootItem['type'] ?? '');
|
|
||||||
$rootFirst = $rootIndex === 0;
|
|
||||||
$rootLast = $rootIndex === ($rootCount - 1);
|
|
||||||
?>
|
|
||||||
<?php if ($rootType === 'bookmark'): ?>
|
|
||||||
<?php
|
|
||||||
$bm = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
|
|
||||||
$bmId = (int) ($bm['id'] ?? 0);
|
|
||||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
|
||||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
|
||||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
|
||||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
|
||||||
?>
|
|
||||||
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
|
|
||||||
data-bookmark-root-kind="bookmark"
|
|
||||||
data-bookmark-root-id="<?php e($bmId); ?>"
|
|
||||||
data-bookmark-id="<?php e($bmId); ?>"
|
|
||||||
data-bookmark-name="<?php e($bmName); ?>"
|
|
||||||
data-bookmark-group-id="">
|
|
||||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
|
||||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
|
||||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
|
||||||
<?php e($bmName); ?>
|
|
||||||
</a>
|
|
||||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
|
||||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
|
||||||
<i class="bi bi-three-dots"></i>
|
|
||||||
</summary>
|
|
||||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
|
||||||
<?php e(t('Edit')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move bookmark up')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move bookmark down')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
|
||||||
<?php e(t('Delete bookmark')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php
|
|
||||||
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
|
|
||||||
$gId = (int) ($group['id'] ?? 0);
|
|
||||||
$gName = trim((string) ($group['name'] ?? ''));
|
|
||||||
$gIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
|
|
||||||
$gBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
|
|
||||||
?>
|
|
||||||
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
|
|
||||||
data-bookmark-root-kind="group"
|
|
||||||
data-bookmark-root-id="<?php e($gId); ?>"
|
|
||||||
data-bookmark-group-id="<?php e($gId); ?>"
|
|
||||||
data-bookmark-group-name="<?php e($gName); ?>"
|
|
||||||
data-bookmark-group-icon="<?php e($gIcon); ?>">
|
|
||||||
<div class="app-sidebar-bookmark-group-shell">
|
|
||||||
<div class="app-sidebar-bookmark-group-header">
|
|
||||||
<small class="app-sidebar-bookmark-group-label">
|
|
||||||
<i class="bi <?php e($gIcon); ?>" aria-hidden="true"></i>
|
|
||||||
<span><?php e($gName); ?></span>
|
|
||||||
</small>
|
|
||||||
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
|
|
||||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
|
||||||
<i class="bi bi-three-dots"></i>
|
|
||||||
</summary>
|
|
||||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-group-action="edit">
|
|
||||||
<?php e(t('Edit')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move group up')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move group down')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
|
|
||||||
<?php e(t('Delete group')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<?php if ($gBookmarks): ?>
|
|
||||||
<ul data-bookmark-group-bookmark-list>
|
|
||||||
<?php $groupBookmarkCount = count($gBookmarks); ?>
|
|
||||||
<?php foreach ($gBookmarks as $bookmarkIndex => $bm): ?>
|
|
||||||
<?php
|
|
||||||
$bmId = (int) ($bm['id'] ?? 0);
|
|
||||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
|
||||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
|
||||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
|
||||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
|
||||||
$bookmarkFirst = $bookmarkIndex === 0;
|
|
||||||
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
|
|
||||||
?>
|
|
||||||
<li class="app-sidebar-bookmark-item"
|
|
||||||
data-bookmark-id="<?php e($bmId); ?>"
|
|
||||||
data-bookmark-name="<?php e($bmName); ?>"
|
|
||||||
data-bookmark-group-id="<?php e($gId); ?>">
|
|
||||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
|
||||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
|
||||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
|
||||||
<?php e($bmName); ?>
|
|
||||||
</a>
|
|
||||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
|
||||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
|
||||||
<i class="bi bi-three-dots"></i>
|
|
||||||
</summary>
|
|
||||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
|
||||||
<?php e(t('Edit')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move bookmark up')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
|
|
||||||
<?php e(t('Move bookmark down')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
|
||||||
<?php e(t('Delete bookmark')); ?>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<dialog
|
<dialog
|
||||||
data-app-session-warning-dialog
|
data-app-session-warning-dialog
|
||||||
|
data-app-component="session-warning"
|
||||||
aria-labelledby="app-session-warning-title"
|
aria-labelledby="app-session-warning-title"
|
||||||
aria-describedby="app-session-warning-message"
|
aria-describedby="app-session-warning-message"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
|
||||||
|
|
||||||
$user = $_SESSION['user'] ?? [];
|
$user = $_SESSION['user'] ?? [];
|
||||||
$accountUrl = accountUrl();
|
$accountUrl = accountUrl();
|
||||||
@@ -22,26 +21,13 @@ if ($tenantLabel === '') {
|
|||||||
}
|
}
|
||||||
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
||||||
$allowUserTheme = allowUserTheme();
|
$allowUserTheme = allowUserTheme();
|
||||||
|
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||||
// Bookmark toggle: check if current page is already bookmarked
|
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||||
$bmData = is_array($_SESSION['user_bookmarks'] ?? null) ? $_SESSION['user_bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||||
$bmCurrentUrl = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
$moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $moduleSlots['topbar.right_item'] : [];
|
||||||
$bmExisting = null;
|
|
||||||
foreach (($bmData['ungrouped'] ?? []) as $bm) {
|
|
||||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
|
||||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break; }
|
|
||||||
}
|
|
||||||
if ($bmExisting === null) {
|
|
||||||
foreach (($bmData['groups'] ?? []) as $grp) {
|
|
||||||
foreach (($grp['bookmarks'] ?? []) as $bm) {
|
|
||||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
|
||||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break 2; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<header class="app-header">
|
<header class="app-header" data-app-component="nav-history">
|
||||||
<nav class="app-topbar">
|
<nav class="app-topbar">
|
||||||
<ul class="app-topbar-left">
|
<ul class="app-topbar-left">
|
||||||
<li>
|
<li>
|
||||||
@@ -55,7 +41,7 @@ if ($bmExisting === null) {
|
|||||||
<div class="app-topbar-center" aria-hidden="true"></div>
|
<div class="app-topbar-center" aria-hidden="true"></div>
|
||||||
<ul class="app-topbar-right">
|
<ul class="app-topbar-right">
|
||||||
<?php if ($canSwitchTenant): ?>
|
<?php if ($canSwitchTenant): ?>
|
||||||
<li class="app-topbar-tenant-slot" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
|
<li class="app-topbar-tenant-slot" data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
|
||||||
<details class="dropdown app-topbar-tenant-menu">
|
<details class="dropdown app-topbar-tenant-menu">
|
||||||
<summary class="app-topbar-tenant-chip" aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
|
<summary class="app-topbar-tenant-chip" aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
|
||||||
<span class="app-topbar-tenant-name"><?php e($tenantLabel); ?></span>
|
<span class="app-topbar-tenant-name"><?php e($tenantLabel); ?></span>
|
||||||
@@ -93,20 +79,24 @@ if ($bmExisting === null) {
|
|||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php foreach ($moduleTopbarSlots as $slot): ?>
|
||||||
|
<?php
|
||||||
|
if (!is_array($slot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||||
|
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slotTemplate = trim((string) ($slot['template'] ?? ''));
|
||||||
|
if ($slotTemplate === '' || !is_file($slotTemplate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
include $slotTemplate;
|
||||||
|
?>
|
||||||
|
<?php endforeach; ?>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" data-bookmark-open-dialog
|
<details class="dropdown app-topbar-user-menu" data-app-component="theme-controls"
|
||||||
aria-label="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>"
|
|
||||||
data-tooltip="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
|
|
||||||
<?php if ($bmExisting): ?>
|
|
||||||
data-bookmark-existing-id="<?php e((int) ($bmExisting['id'] ?? 0)); ?>"
|
|
||||||
data-bookmark-existing-name="<?php e(trim((string) ($bmExisting['name'] ?? ''))); ?>"
|
|
||||||
data-bookmark-existing-group="<?php e($bmExisting['group_id'] ?? ''); ?>"
|
|
||||||
<?php endif; ?>>
|
|
||||||
<i class="bi <?php e($bmExisting ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<details class="dropdown app-topbar-user-menu"
|
|
||||||
<?php if ($allowUserTheme): ?>
|
<?php if ($allowUserTheme): ?>
|
||||||
data-theme-menu
|
data-theme-menu
|
||||||
data-theme-url="admin/users/theme"
|
data-theme-url="admin/users/theme"
|
||||||
@@ -125,7 +115,7 @@ if ($bmExisting === null) {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="app-topbar-menu-item-detail-sidebar">
|
<li class="app-topbar-menu-item-detail-sidebar">
|
||||||
<button type="button" id="toggle-main-content-aside" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
|
<button type="button" id="toggle-main-content-aside" data-app-component="details-aside-toggle" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
|
||||||
<?php e(t('Toggle Detail Sidebar')); ?>
|
<?php e(t('Toggle Detail Sidebar')); ?>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -147,7 +137,7 @@ if ($bmExisting === null) {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
|
<button type="button" data-app-component="contrast-toggle" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
|
||||||
<?php e(t('High contrast')); ?>
|
<?php e(t('High contrast')); ?>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
40
tests/App/AppContainerTest.php
Normal file
40
tests/App/AppContainerTest.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\App;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class AppContainerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testOverwritingBindingIsAllowedBeforeProtection(): void
|
||||||
|
{
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set('service.test', static fn (): string => 'first');
|
||||||
|
$container->set('service.test', static fn (): string => 'second');
|
||||||
|
|
||||||
|
self::assertSame('second', $container->get('service.test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverwritingExistingBindingIsBlockedAfterProtection(): void
|
||||||
|
{
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set('service.test', static fn (): string => 'first');
|
||||||
|
$container->protectExistingBindings();
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Refusing to overwrite existing service binding: service.test');
|
||||||
|
$container->set('service.test', static fn (): string => 'second');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddingNewBindingStillWorksAfterProtection(): void
|
||||||
|
{
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set('service.first', static fn (): string => 'first');
|
||||||
|
$container->protectExistingBindings();
|
||||||
|
$container->set('service.second', static fn (): string => 'second');
|
||||||
|
|
||||||
|
self::assertSame('second', $container->get('service.second'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,32 @@ final class ModuleManifestTest extends TestCase
|
|||||||
'ui_slots' => ['aside.tab_panel' => [['key' => 'people', 'label' => 'People']]],
|
'ui_slots' => ['aside.tab_panel' => [['key' => 'people', 'label' => 'People']]],
|
||||||
'search_resources' => ['App\\SearchProvider'],
|
'search_resources' => ['App\\SearchProvider'],
|
||||||
'asset_groups' => ['address-book' => ['css/view.css']],
|
'asset_groups' => ['address-book' => ['css/view.css']],
|
||||||
'scheduler_jobs' => [],
|
'scheduler_jobs' => [
|
||||||
|
[
|
||||||
|
'job_key' => 'addressbook.sync',
|
||||||
|
'handler' => 'App\\Scheduler\\AddressBookSyncHandler',
|
||||||
|
'label' => 'Address book sync',
|
||||||
|
'description' => 'Syncs address data',
|
||||||
|
'default_enabled' => true,
|
||||||
|
'default_timezone' => 'Europe/Berlin',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '02:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => true,
|
||||||
|
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||||
|
],
|
||||||
|
],
|
||||||
'layout_context_providers' => ['App\\LayoutProvider'],
|
'layout_context_providers' => ['App\\LayoutProvider'],
|
||||||
'session_providers' => ['App\\SessionProvider'],
|
'session_providers' => ['App\\SessionProvider'],
|
||||||
'permissions' => ['address_book.view'],
|
'permissions' => [
|
||||||
|
[
|
||||||
|
'key' => 'address_book.view',
|
||||||
|
'description' => 'Can view address book',
|
||||||
|
'active' => true,
|
||||||
|
'is_system' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
'migrations_path' => 'db/updates',
|
'migrations_path' => 'db/updates',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +71,13 @@ final class ModuleManifestTest extends TestCase
|
|||||||
self::assertTrue($manifest->enabledByDefault);
|
self::assertTrue($manifest->enabledByDefault);
|
||||||
self::assertSame(10, $manifest->loadOrder);
|
self::assertSame(10, $manifest->loadOrder);
|
||||||
self::assertCount(1, $manifest->routes);
|
self::assertCount(1, $manifest->routes);
|
||||||
self::assertSame(['address_book.view'], $manifest->permissions);
|
self::assertSame([[
|
||||||
|
'key' => 'address_book.view',
|
||||||
|
'description' => 'Can view address book',
|
||||||
|
'active' => 1,
|
||||||
|
'is_system' => 1,
|
||||||
|
]], $manifest->permissions);
|
||||||
|
self::assertSame('addressbook.sync', $manifest->schedulerJobs[0]['job_key']);
|
||||||
self::assertSame('/srv/modules/addressbook/db/updates', $manifest->migrationsPath);
|
self::assertSame('/srv/modules/addressbook/db/updates', $manifest->migrationsPath);
|
||||||
self::assertSame(['App\\LayoutProvider'], $manifest->layoutContextProviders);
|
self::assertSame(['App\\LayoutProvider'], $manifest->layoutContextProviders);
|
||||||
self::assertSame(['App\\SessionProvider'], $manifest->sessionProviders);
|
self::assertSame(['App\\SessionProvider'], $manifest->sessionProviders);
|
||||||
@@ -69,4 +97,38 @@ final class ModuleManifestTest extends TestCase
|
|||||||
|
|
||||||
ModuleManifest::fromArray(['id' => ' '], '/tmp/modules/broken');
|
ModuleManifest::fromArray(['id' => ' '], '/tmp/modules/broken');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInvalidPermissionContractThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage("must define non-empty 'key' and 'description'");
|
||||||
|
|
||||||
|
ModuleManifest::fromArray([
|
||||||
|
'id' => 'mod-invalid-perm',
|
||||||
|
'permissions' => [['key' => 'mod.permission']],
|
||||||
|
], '/tmp/modules/mod-invalid-perm');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidSchedulerJobContractThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage("missing required key 'allowed_schedule_types'");
|
||||||
|
|
||||||
|
ModuleManifest::fromArray([
|
||||||
|
'id' => 'mod-invalid-job',
|
||||||
|
'scheduler_jobs' => [[
|
||||||
|
'job_key' => 'mod.job',
|
||||||
|
'handler' => 'App\\Handler',
|
||||||
|
'label' => 'Label',
|
||||||
|
'description' => 'Desc',
|
||||||
|
'default_enabled' => true,
|
||||||
|
'default_timezone' => 'UTC',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '01:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => true,
|
||||||
|
]],
|
||||||
|
], '/tmp/modules/mod-invalid-job');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
181
tests/App/Module/ModulePermissionSynchronizerTest.php
Normal file
181
tests/App/Module/ModulePermissionSynchronizerTest.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModulePermissionSynchronizerTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $fixturesDir = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->fixturesDir = sys_get_temp_dir() . '/module-permission-sync-' . uniqid();
|
||||||
|
mkdir($this->fixturesDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDir($this->fixturesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSyncCreatesPermissionAndIsIdempotent(): void
|
||||||
|
{
|
||||||
|
$registry = $this->createRegistryWithPermissions([[
|
||||||
|
'key' => 'address_book.view',
|
||||||
|
'description' => 'Can view address book',
|
||||||
|
'active' => 1,
|
||||||
|
'is_system' => 1,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$repository = new InMemoryPermissionRepository();
|
||||||
|
$synchronizer = new ModulePermissionSynchronizer($registry, $repository);
|
||||||
|
|
||||||
|
$first = $synchronizer->sync();
|
||||||
|
self::assertSame(1, $first['created']);
|
||||||
|
self::assertSame(0, $first['updated']);
|
||||||
|
self::assertSame(0, $first['unchanged']);
|
||||||
|
|
||||||
|
$second = $synchronizer->sync();
|
||||||
|
self::assertSame(0, $second['created']);
|
||||||
|
self::assertSame(0, $second['updated']);
|
||||||
|
self::assertSame(1, $second['unchanged']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSyncUpdatesExistingPermissionWhenDefinitionChanged(): void
|
||||||
|
{
|
||||||
|
$registry = $this->createRegistryWithPermissions([[
|
||||||
|
'key' => 'address_book.view',
|
||||||
|
'description' => 'Can view address book',
|
||||||
|
'active' => 1,
|
||||||
|
'is_system' => 1,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$repository = new InMemoryPermissionRepository();
|
||||||
|
$repository->create([
|
||||||
|
'key' => 'address_book.view',
|
||||||
|
'description' => 'Old description',
|
||||||
|
'active' => 0,
|
||||||
|
'is_system' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$synchronizer = new ModulePermissionSynchronizer($registry, $repository);
|
||||||
|
$result = $synchronizer->sync();
|
||||||
|
|
||||||
|
self::assertSame(0, $result['created']);
|
||||||
|
self::assertSame(1, $result['updated']);
|
||||||
|
self::assertSame(0, $result['unchanged']);
|
||||||
|
|
||||||
|
$permission = $repository->findByKey('address_book.view');
|
||||||
|
self::assertSame('Can view address book', $permission['description'] ?? null);
|
||||||
|
self::assertSame(1, (int) ($permission['active'] ?? 0));
|
||||||
|
self::assertSame(1, (int) ($permission['is_system'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{key: string, description: string, active: int, is_system: int}> $permissions
|
||||||
|
*/
|
||||||
|
private function createRegistryWithPermissions(array $permissions): ModuleRegistry
|
||||||
|
{
|
||||||
|
mkdir($this->fixturesDir . '/mod-perm', 0777, true);
|
||||||
|
file_put_contents(
|
||||||
|
$this->fixturesDir . '/mod-perm/module.php',
|
||||||
|
'<?php return ' . var_export([
|
||||||
|
'id' => 'mod-perm',
|
||||||
|
'permissions' => $permissions,
|
||||||
|
], true) . ';'
|
||||||
|
);
|
||||||
|
|
||||||
|
return ModuleRegistry::boot($this->fixturesDir, ['mod-perm']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$items = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class InMemoryPermissionRepository implements PermissionRepositoryInterface
|
||||||
|
{
|
||||||
|
/** @var array<int, array<string, mixed>> */
|
||||||
|
private array $rows = [];
|
||||||
|
private int $nextId = 1;
|
||||||
|
|
||||||
|
public function list(): array
|
||||||
|
{
|
||||||
|
return array_values($this->rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listActive(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->rows, static fn (array $row): bool => (int) ($row['active'] ?? 0) === 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listPaged(array $options): array
|
||||||
|
{
|
||||||
|
return ['rows' => $this->list(), 'total' => count($this->rows)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->rows[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByKey(string $key): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->rows as $row) {
|
||||||
|
if ((string) ($row['key'] ?? '') === $key) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): ?int
|
||||||
|
{
|
||||||
|
$id = $this->nextId++;
|
||||||
|
$this->rows[$id] = [
|
||||||
|
'id' => $id,
|
||||||
|
'key' => (string) ($data['key'] ?? ''),
|
||||||
|
'description' => (string) ($data['description'] ?? ''),
|
||||||
|
'active' => (int) ($data['active'] ?? 1),
|
||||||
|
'is_system' => (int) ($data['is_system'] ?? 1),
|
||||||
|
];
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): bool
|
||||||
|
{
|
||||||
|
if (!isset($this->rows[$id])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->rows[$id]['key'] = (string) ($data['key'] ?? $this->rows[$id]['key']);
|
||||||
|
$this->rows[$id]['description'] = (string) ($data['description'] ?? $this->rows[$id]['description']);
|
||||||
|
$this->rows[$id]['active'] = (int) ($data['active'] ?? $this->rows[$id]['active']);
|
||||||
|
$this->rows[$id]['is_system'] = (int) ($data['is_system'] ?? $this->rows[$id]['is_system']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool
|
||||||
|
{
|
||||||
|
unset($this->rows[$id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,10 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
'id' => 'testmod',
|
'id' => 'testmod',
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'load_order' => 10,
|
'load_order' => 10,
|
||||||
'permissions' => ['test.view', 'test.edit'],
|
'permissions' => [
|
||||||
|
['key' => 'test.view', 'description' => 'Test view'],
|
||||||
|
['key' => 'test.edit', 'description' => 'Test edit'],
|
||||||
|
],
|
||||||
'search_resources' => [],
|
'search_resources' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
|
|
||||||
self::assertTrue($registry->hasModule('testmod'));
|
self::assertTrue($registry->hasModule('testmod'));
|
||||||
self::assertSame('1.0.0', $registry->getModule('testmod')->version);
|
self::assertSame('1.0.0', $registry->getModule('testmod')->version);
|
||||||
self::assertSame(['test.view', 'test.edit'], $registry->getPermissions());
|
self::assertSame(['test.view', 'test.edit'], $registry->getPermissionKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeterministicOrdering(): void
|
public function testDeterministicOrdering(): void
|
||||||
@@ -80,8 +83,16 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
|
|
||||||
public function testPermissionConflictThrows(): void
|
public function testPermissionConflictThrows(): void
|
||||||
{
|
{
|
||||||
$this->createModuleManifest('mod-a', ['id' => 'mod-a', 'load_order' => 1, 'permissions' => ['shared.perm']]);
|
$this->createModuleManifest('mod-a', [
|
||||||
$this->createModuleManifest('mod-b', ['id' => 'mod-b', 'load_order' => 2, 'permissions' => ['shared.perm']]);
|
'id' => 'mod-a',
|
||||||
|
'load_order' => 1,
|
||||||
|
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission']],
|
||||||
|
]);
|
||||||
|
$this->createModuleManifest('mod-b', [
|
||||||
|
'id' => 'mod-b',
|
||||||
|
'load_order' => 2,
|
||||||
|
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission duplicate']],
|
||||||
|
]);
|
||||||
|
|
||||||
$this->expectException(RuntimeException::class);
|
$this->expectException(RuntimeException::class);
|
||||||
$this->expectExceptionMessage("Permission conflict: 'shared.perm'");
|
$this->expectExceptionMessage("Permission conflict: 'shared.perm'");
|
||||||
@@ -108,6 +119,137 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRoutePathConflictThrows(): void
|
||||||
|
{
|
||||||
|
$this->createModuleManifest('mod-a', [
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'load_order' => 1,
|
||||||
|
'routes' => [['path' => '/shared-path', 'target' => 'a/index']],
|
||||||
|
]);
|
||||||
|
$this->createModuleManifest('mod-b', [
|
||||||
|
'id' => 'mod-b',
|
||||||
|
'load_order' => 2,
|
||||||
|
'routes' => [['path' => '/shared-path', 'target' => 'b/index']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Route path conflict');
|
||||||
|
|
||||||
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUiSlotConflictThrows(): void
|
||||||
|
{
|
||||||
|
$panelTemplateA = $this->createFixtureFile('mod-a-panel.phtml', '<ul></ul>');
|
||||||
|
$panelTemplateB = $this->createFixtureFile('mod-b-panel.phtml', '<ul></ul>');
|
||||||
|
|
||||||
|
$this->createModuleManifest('mod-a', [
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'load_order' => 1,
|
||||||
|
'ui_slots' => [
|
||||||
|
'aside.tab_panel' => [
|
||||||
|
[
|
||||||
|
'key' => 'people',
|
||||||
|
'label' => 'People',
|
||||||
|
'icon' => 'bi-people',
|
||||||
|
'permission' => 'can_view_people',
|
||||||
|
'panel_template' => $panelTemplateA,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->createModuleManifest('mod-b', [
|
||||||
|
'id' => 'mod-b',
|
||||||
|
'load_order' => 2,
|
||||||
|
'ui_slots' => [
|
||||||
|
'aside.tab_panel' => [
|
||||||
|
[
|
||||||
|
'key' => 'people',
|
||||||
|
'label' => 'People B',
|
||||||
|
'icon' => 'bi-people',
|
||||||
|
'permission' => 'can_view_people',
|
||||||
|
'panel_template' => $panelTemplateB,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage("UI slot conflict: slot 'aside.tab_panel' key 'people'");
|
||||||
|
|
||||||
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidRouteContractThrows(): void
|
||||||
|
{
|
||||||
|
$this->createModuleManifest('mod-a', [
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'routes' => [['path' => '', 'target' => 'address-book/index']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage("must define non-empty 'path' and 'target'");
|
||||||
|
|
||||||
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidUiSlotContractThrows(): void
|
||||||
|
{
|
||||||
|
$this->createModuleManifest('mod-a', [
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'ui_slots' => [
|
||||||
|
'aside.tab_panel' => [
|
||||||
|
['label' => 'Missing Key'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage("must define a non-empty 'key'");
|
||||||
|
|
||||||
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUiSlotsAreSortedDeterministicallyByOrderModuleAndKey(): void
|
||||||
|
{
|
||||||
|
$this->createModuleManifest('mod-z', [
|
||||||
|
'id' => 'mod-z',
|
||||||
|
'load_order' => 1,
|
||||||
|
'ui_slots' => [
|
||||||
|
'search.resource_item' => [
|
||||||
|
['key' => 'z-second', 'label' => 'Z Second', 'base_url' => 'z-second', 'permission' => 'can.z', 'order' => 20],
|
||||||
|
['key' => 'z-first', 'label' => 'Z First', 'base_url' => 'z-first', 'permission' => 'can.z', 'order' => 20],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->createModuleManifest('mod-a', [
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'load_order' => 1,
|
||||||
|
'ui_slots' => [
|
||||||
|
'search.resource_item' => [
|
||||||
|
['key' => 'a-mid', 'label' => 'A Mid', 'base_url' => 'a-mid', 'permission' => 'can.a', 'order' => 20],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->createModuleManifest('mod-b', [
|
||||||
|
'id' => 'mod-b',
|
||||||
|
'load_order' => 1,
|
||||||
|
'ui_slots' => [
|
||||||
|
'search.resource_item' => [
|
||||||
|
['key' => 'b-low', 'label' => 'B Low', 'base_url' => 'b-low', 'permission' => 'can.b', 'order' => 10],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$registry = ModuleRegistry::boot($this->fixturesDir, ['mod-z', 'mod-b', 'mod-a']);
|
||||||
|
$slotItems = $registry->getSlotContributions('search.resource_item');
|
||||||
|
|
||||||
|
self::assertSame(['b-low', 'a-mid', 'z-first', 'z-second'], array_column($slotItems, 'key'));
|
||||||
|
foreach ($slotItems as $slotItem) {
|
||||||
|
self::assertArrayNotHasKey('__module_id', $slotItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testMissingManifestFileThrows(): void
|
public function testMissingManifestFileThrows(): void
|
||||||
{
|
{
|
||||||
$this->expectException(RuntimeException::class);
|
$this->expectException(RuntimeException::class);
|
||||||
@@ -126,16 +268,21 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
|
ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testResolveEnabledModulesFromConfig(): void
|
public function testResolveEnabledModulesFromConfigWhenEnvUnset(): void
|
||||||
{
|
{
|
||||||
$result = ModuleRegistry::resolveEnabledModules([
|
putenv('APP_ENABLED_MODULES');
|
||||||
'enabled_modules' => ['mod-a', 'mod-b'],
|
try {
|
||||||
]);
|
$result = ModuleRegistry::resolveEnabledModules([
|
||||||
|
'enabled_modules' => ['mod-a', 'mod-b'],
|
||||||
|
]);
|
||||||
|
|
||||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||||
|
} finally {
|
||||||
|
putenv('APP_ENABLED_MODULES');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testResolveEnabledModulesFromEnv(): void
|
public function testResolveEnabledModulesFromEnvList(): void
|
||||||
{
|
{
|
||||||
putenv('APP_ENABLED_MODULES=env-a,env-b');
|
putenv('APP_ENABLED_MODULES=env-a,env-b');
|
||||||
|
|
||||||
@@ -149,13 +296,32 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testResolveEnabledModulesFromEmptyEnvReturnsNoModules(): void
|
||||||
|
{
|
||||||
|
putenv('APP_ENABLED_MODULES=');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = ModuleRegistry::resolveEnabledModules([
|
||||||
|
'enabled_modules' => ['config-a', 'config-b'],
|
||||||
|
]);
|
||||||
|
self::assertSame([], $result);
|
||||||
|
} finally {
|
||||||
|
putenv('APP_ENABLED_MODULES');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testResolveEnabledModulesDeduplicates(): void
|
public function testResolveEnabledModulesDeduplicates(): void
|
||||||
{
|
{
|
||||||
$result = ModuleRegistry::resolveEnabledModules([
|
putenv('APP_ENABLED_MODULES');
|
||||||
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
|
try {
|
||||||
]);
|
$result = ModuleRegistry::resolveEnabledModules([
|
||||||
|
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
|
||||||
|
]);
|
||||||
|
|
||||||
self::assertSame(['mod-a', 'mod-b'], $result);
|
self::assertSame(['mod-a', 'mod-b'], $result);
|
||||||
|
} finally {
|
||||||
|
putenv('APP_ENABLED_MODULES');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetNonExistentModuleThrows(): void
|
public function testGetNonExistentModuleThrows(): void
|
||||||
@@ -212,6 +378,17 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createFixtureFile(string $relativePath, string $content): string
|
||||||
|
{
|
||||||
|
$path = $this->fixturesDir . '/' . ltrim($relativePath, '/');
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
private function removeDir(string $dir): void
|
private function removeDir(string $dir): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
|
|||||||
142
tests/App/Module/ModuleRuntimeAssetPublisherTest.php
Normal file
142
tests/App/Module/ModuleRuntimeAssetPublisherTest.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleManifest;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModuleRuntimeAssetPublisherTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tmpDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tmpDir = sys_get_temp_dir() . '/corecore-module-assets-test-' . uniqid();
|
||||||
|
mkdir($this->tmpDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removePath($this->tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublishSymlinkCreatesManagedModuleLinksAndRemovesStaleEntries(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('symlink')) {
|
||||||
|
self::markTestSkipped('Symlink support is required for this test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleA = $this->createModuleWithWebAssets('module-a', ['js/a.js' => 'console.log("a");']);
|
||||||
|
$moduleB = $this->createModuleWithoutWebAssets('module-b');
|
||||||
|
$runtimeModulesDir = $this->tmpDir . '/web/modules';
|
||||||
|
mkdir($runtimeModulesDir, 0777, true);
|
||||||
|
file_put_contents($runtimeModulesDir . '/stale.txt', 'old');
|
||||||
|
|
||||||
|
$publisher = new ModuleRuntimeAssetPublisher();
|
||||||
|
$result = $publisher->publish($runtimeModulesDir, [
|
||||||
|
'module-a' => $moduleA,
|
||||||
|
'module-b' => $moduleB,
|
||||||
|
], 'symlink');
|
||||||
|
|
||||||
|
self::assertTrue(is_link($runtimeModulesDir . '/module-a'));
|
||||||
|
self::assertSame(
|
||||||
|
realpath($this->tmpDir . '/modules/module-a/web'),
|
||||||
|
realpath($runtimeModulesDir . '/module-a')
|
||||||
|
);
|
||||||
|
self::assertFileDoesNotExist($runtimeModulesDir . '/module-b');
|
||||||
|
self::assertFileDoesNotExist($runtimeModulesDir . '/stale.txt');
|
||||||
|
self::assertSame(1, $result['created']);
|
||||||
|
self::assertSame(1, $result['removed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublishSymlinkUpdatesWrongExistingLink(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('symlink')) {
|
||||||
|
self::markTestSkipped('Symlink support is required for this test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleA = $this->createModuleWithWebAssets('module-a', ['css/a.css' => '.a{}']);
|
||||||
|
$runtimeModulesDir = $this->tmpDir . '/web/modules';
|
||||||
|
mkdir($runtimeModulesDir, 0777, true);
|
||||||
|
|
||||||
|
$wrongTarget = $this->tmpDir . '/wrong-target';
|
||||||
|
mkdir($wrongTarget, 0777, true);
|
||||||
|
symlink($wrongTarget, $runtimeModulesDir . '/module-a');
|
||||||
|
|
||||||
|
$publisher = new ModuleRuntimeAssetPublisher();
|
||||||
|
$result = $publisher->publish($runtimeModulesDir, ['module-a' => $moduleA], 'symlink');
|
||||||
|
|
||||||
|
self::assertTrue(is_link($runtimeModulesDir . '/module-a'));
|
||||||
|
self::assertSame(
|
||||||
|
realpath($this->tmpDir . '/modules/module-a/web'),
|
||||||
|
realpath($runtimeModulesDir . '/module-a')
|
||||||
|
);
|
||||||
|
self::assertSame(1, $result['updated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublishCopyModeCopiesModuleAssets(): void
|
||||||
|
{
|
||||||
|
$moduleA = $this->createModuleWithWebAssets('module-a', [
|
||||||
|
'js/app.js' => 'console.log("copy-mode");',
|
||||||
|
'css/app.css' => '.copy-mode{}',
|
||||||
|
]);
|
||||||
|
$runtimeModulesDir = $this->tmpDir . '/web/modules';
|
||||||
|
|
||||||
|
$publisher = new ModuleRuntimeAssetPublisher();
|
||||||
|
$result = $publisher->publish($runtimeModulesDir, ['module-a' => $moduleA], 'copy');
|
||||||
|
|
||||||
|
self::assertDirectoryExists($runtimeModulesDir . '/module-a');
|
||||||
|
self::assertFalse(is_link($runtimeModulesDir . '/module-a'));
|
||||||
|
self::assertFileExists($runtimeModulesDir . '/module-a/js/app.js');
|
||||||
|
self::assertFileExists($runtimeModulesDir . '/module-a/css/app.css');
|
||||||
|
self::assertSame(1, $result['created']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createModuleWithWebAssets(string $id, array $files): ModuleManifest
|
||||||
|
{
|
||||||
|
$moduleDir = $this->tmpDir . '/modules/' . $id;
|
||||||
|
mkdir($moduleDir . '/web', 0777, true);
|
||||||
|
foreach ($files as $relativePath => $content) {
|
||||||
|
$target = $moduleDir . '/web/' . ltrim($relativePath, '/');
|
||||||
|
$targetDir = dirname($target);
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
mkdir($targetDir, 0777, true);
|
||||||
|
}
|
||||||
|
file_put_contents($target, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createModuleWithoutWebAssets(string $id): ModuleManifest
|
||||||
|
{
|
||||||
|
$moduleDir = $this->tmpDir . '/modules/' . $id;
|
||||||
|
mkdir($moduleDir, 0777, true);
|
||||||
|
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removePath(string $path): void
|
||||||
|
{
|
||||||
|
if (!file_exists($path) && !is_link($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_link($path) || is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$entries = scandir($path);
|
||||||
|
if ($entries !== false) {
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->removePath($path . '/' . $entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tests/App/Module/RouterConfigCollisionTest.php
Normal file
55
tests/App/Module/RouterConfigCollisionTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RouterConfigCollisionTest extends TestCase
|
||||||
|
{
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
*/
|
||||||
|
public function testModuleRoutePathCollidingWithCoreRouteThrows(): void
|
||||||
|
{
|
||||||
|
$fixturesDir = sys_get_temp_dir() . '/router-config-collision-' . uniqid();
|
||||||
|
mkdir($fixturesDir . '/mod-a', 0777, true);
|
||||||
|
file_put_contents(
|
||||||
|
$fixturesDir . '/mod-a/module.php',
|
||||||
|
'<?php return ' . var_export([
|
||||||
|
'id' => 'mod-a',
|
||||||
|
'routes' => [
|
||||||
|
['path' => 'login', 'target' => 'mod-a/login'],
|
||||||
|
],
|
||||||
|
], true) . ';'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$registry = ModuleRegistry::boot($fixturesDir, ['mod-a']);
|
||||||
|
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
|
||||||
|
$this->pushAppContainer($container);
|
||||||
|
|
||||||
|
if (!defined('APP_ROUTE_DEFINITIONS')) {
|
||||||
|
define('APP_ROUTE_DEFINITIONS', [
|
||||||
|
['path' => 'login', 'target' => 'auth/login'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Module route path conflict');
|
||||||
|
|
||||||
|
require dirname(__DIR__, 3) . '/config/router.php';
|
||||||
|
} finally {
|
||||||
|
$this->restoreAppContainer();
|
||||||
|
@unlink($fixturesDir . '/mod-a/module.php');
|
||||||
|
@rmdir($fixturesDir . '/mod-a');
|
||||||
|
@rmdir($fixturesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Architecture/AppContainerIsolationContractTest.php
Normal file
34
tests/Architecture/AppContainerIsolationContractTest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract: Direct writes to $GLOBALS['minty_app_container'] are only allowed
|
||||||
|
* inside the dedicated AppContainerIsolationTrait.
|
||||||
|
*/
|
||||||
|
final class AppContainerIsolationContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
public function testDirectAppContainerGlobalWritesAreRestrictedToIsolationTrait(): void
|
||||||
|
{
|
||||||
|
$violations = array_merge(
|
||||||
|
$this->findPatternMatchesInPhpFiles('tests', '/\\$GLOBALS\\[\'minty_app_container\'\\]\\s*=/'),
|
||||||
|
$this->findPatternMatchesInPhpFiles('tests', '/unset\\(\\$GLOBALS\\[\'minty_app_container\'\\]\\)/')
|
||||||
|
);
|
||||||
|
|
||||||
|
$allowed = [
|
||||||
|
'tests/Support/AppContainerIsolationTrait.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$violations = array_values(array_filter(
|
||||||
|
array_unique($violations),
|
||||||
|
static fn (string $path): bool => !in_array($path, $allowed, true)
|
||||||
|
));
|
||||||
|
sort($violations);
|
||||||
|
|
||||||
|
self::assertSame([], $violations, "Direct global app-container writes found outside isolation trait:\n" . implode("\n", $violations));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class ContainerFactoryContractTest extends TestCase
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
[\MintyPHP\Service\Access\AccessServicesFactory::class],
|
[\MintyPHP\Service\Access\AccessServicesFactory::class],
|
||||||
[\MintyPHP\Service\AddressBook\AddressBookServicesFactory::class],
|
[\MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory::class],
|
||||||
[\MintyPHP\Service\Audit\AuditServicesFactory::class],
|
[\MintyPHP\Service\Audit\AuditServicesFactory::class],
|
||||||
[\MintyPHP\Service\Auth\AuthServicesFactory::class],
|
[\MintyPHP\Service\Auth\AuthServicesFactory::class],
|
||||||
[\MintyPHP\Service\Branding\BrandingServicesFactory::class],
|
[\MintyPHP\Service\Branding\BrandingServicesFactory::class],
|
||||||
|
|||||||
58
tests/Architecture/CoreTemplateIsolationTest.php
Normal file
58
tests/Architecture/CoreTemplateIsolationTest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use FilesystemIterator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
|
||||||
|
final class CoreTemplateIsolationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCoreTemplatesDoNotReferenceModuleSpecificLayoutNavKeys(): void
|
||||||
|
{
|
||||||
|
$projectRoot = dirname(__DIR__, 2);
|
||||||
|
$templatesDir = $projectRoot . '/templates';
|
||||||
|
$modulesDir = $projectRoot . '/modules';
|
||||||
|
|
||||||
|
if (!is_dir($templatesDir) || !is_dir($modulesDir)) {
|
||||||
|
self::markTestSkipped('templates/ or modules/ directory missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleIds = [];
|
||||||
|
foreach (scandir($modulesDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_dir($modulesDir . '/' . $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$moduleIds[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$violations = [];
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($templatesDir, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->getExtension() !== 'phtml') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $file->getPathname();
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
if (!is_string($content)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($moduleIds as $moduleId) {
|
||||||
|
if (preg_match("/layoutNav\[['\"]" . preg_quote($moduleId, '/') . "(\\.|['\"])" . "/", $content) === 1) {
|
||||||
|
$violations[] = str_replace($projectRoot . '/', '', $path) . ' references layoutNav module key for ' . $moduleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame([], $violations, "Core templates reference module layout keys:\n" . implode("\n", $violations));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ class DetailPageContractTest extends TestCase
|
|||||||
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
|
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
|
||||||
{
|
{
|
||||||
$content = $this->readProjectFile('web/js/app-init.js');
|
$content = $this->readProjectFile('web/js/app-init.js');
|
||||||
$this->assertStringContainsString("import './components/app-standard-detail-page.js';", $content);
|
$this->assertStringContainsString("import { initStandardDetailPages } from './components/app-standard-detail-page.js';", $content);
|
||||||
|
$this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
|
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ class FilterDrawerContractTest extends TestCase
|
|||||||
private function readTemplatePageModule(string $templateFile): string
|
private function readTemplatePageModule(string $templateFile): string
|
||||||
{
|
{
|
||||||
$template = $this->readProjectFile($templateFile);
|
$template = $this->readProjectFile($templateFile);
|
||||||
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
|
$matched = preg_match(
|
||||||
|
"/assetVersion\\('((?:modules\\/[^\\/]+\\/)?js\\/pages\\/[^']+\\.js)'\\)/",
|
||||||
|
$template,
|
||||||
|
$captures
|
||||||
|
);
|
||||||
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
|
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
|
||||||
|
|
||||||
return $this->readProjectFile('web/js/pages/' . $captures[1]);
|
return $this->readProjectFile('web/' . ltrim($captures[1], '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function usesSharedListFiltersPartial(string $content): bool
|
private function usesSharedListFiltersPartial(string $content): bool
|
||||||
@@ -28,7 +32,7 @@ class FilterDrawerContractTest extends TestCase
|
|||||||
private function drawerTemplateFiles(): array
|
private function drawerTemplateFiles(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/index(default).phtml',
|
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||||
'pages/admin/users/index(default).phtml',
|
'pages/admin/users/index(default).phtml',
|
||||||
'pages/admin/tenants/index(default).phtml',
|
'pages/admin/tenants/index(default).phtml',
|
||||||
'pages/admin/departments/index(default).phtml',
|
'pages/admin/departments/index(default).phtml',
|
||||||
@@ -64,8 +68,8 @@ class FilterDrawerContractTest extends TestCase
|
|||||||
|
|
||||||
public function testAddressBookFilterChipsContract(): void
|
public function testAddressBookFilterChipsContract(): void
|
||||||
{
|
{
|
||||||
$content = $this->readProjectFile('pages/address-book/index(default).phtml');
|
$content = $this->readProjectFile('modules/addressbook/pages/address-book/index(default).phtml');
|
||||||
$moduleContent = $this->readProjectFile('web/js/pages/address-book-index.js');
|
$moduleContent = $this->readProjectFile('modules/addressbook/web/js/pages/address-book-index.js');
|
||||||
|
|
||||||
$this->assertStringNotContainsString('const state = collectFilterState();', $moduleContent);
|
$this->assertStringNotContainsString('const state = collectFilterState();', $moduleContent);
|
||||||
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
|
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function readTemplatePageModule(string $templateFile): string
|
private function readTemplatePageModule(string $templateFile): string
|
||||||
{
|
{
|
||||||
$template = $this->readProjectFile($templateFile);
|
$template = $this->readProjectFile($templateFile);
|
||||||
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
|
$matched = preg_match(
|
||||||
|
"/assetVersion\\('((?:modules\\/[^\\/]+\\/)?js\\/pages\\/[^']+\\.js)'\\)/",
|
||||||
|
$template,
|
||||||
|
$captures
|
||||||
|
);
|
||||||
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
|
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
|
||||||
|
|
||||||
return $this->readProjectFile('web/js/pages/' . $captures[1]);
|
return $this->readProjectFile('web/' . ltrim($captures[1], '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function usesSharedListFiltersPartial(string $content): bool
|
private function usesSharedListFiltersPartial(string $content): bool
|
||||||
@@ -28,7 +32,7 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function hardCutGridEndpointFiles(): array
|
private function hardCutGridEndpointFiles(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/data().php',
|
'modules/addressbook/pages/address-book/data().php',
|
||||||
'pages/search/data().php',
|
'pages/search/data().php',
|
||||||
'pages/admin/users/data().php',
|
'pages/admin/users/data().php',
|
||||||
'pages/admin/tenants/data().php',
|
'pages/admin/tenants/data().php',
|
||||||
@@ -51,7 +55,7 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function hardCutGridEndpointSchemaFiles(): array
|
private function hardCutGridEndpointSchemaFiles(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
'modules/addressbook/pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||||
'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||||
'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||||
'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||||
@@ -74,7 +78,7 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function listIndexTemplates(): array
|
private function listIndexTemplates(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/index(default).phtml',
|
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||||
'pages/search/index(default).phtml',
|
'pages/search/index(default).phtml',
|
||||||
'pages/admin/users/index(default).phtml',
|
'pages/admin/users/index(default).phtml',
|
||||||
'pages/admin/tenants/index(default).phtml',
|
'pages/admin/tenants/index(default).phtml',
|
||||||
@@ -96,7 +100,7 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function listIndexActions(): array
|
private function listIndexActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/index().php',
|
'modules/addressbook/pages/address-book/index().php',
|
||||||
'pages/search/index().php',
|
'pages/search/index().php',
|
||||||
'pages/admin/users/index().php',
|
'pages/admin/users/index().php',
|
||||||
'pages/admin/tenants/index().php',
|
'pages/admin/tenants/index().php',
|
||||||
@@ -118,7 +122,7 @@ class ListFilterContractTest extends TestCase
|
|||||||
private function drawerListTemplates(): array
|
private function drawerListTemplates(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pages/address-book/index(default).phtml',
|
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||||
'pages/admin/users/index(default).phtml',
|
'pages/admin/users/index(default).phtml',
|
||||||
'pages/admin/tenants/index(default).phtml',
|
'pages/admin/tenants/index(default).phtml',
|
||||||
'pages/admin/departments/index(default).phtml',
|
'pages/admin/departments/index(default).phtml',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ListTitlebarContractTest extends TestCase
|
|||||||
'pages/admin/import-audit/index(default).phtml',
|
'pages/admin/import-audit/index(default).phtml',
|
||||||
'pages/admin/user-lifecycle-audit/index(default).phtml',
|
'pages/admin/user-lifecycle-audit/index(default).phtml',
|
||||||
'pages/admin/system-audit/index(default).phtml',
|
'pages/admin/system-audit/index(default).phtml',
|
||||||
'pages/address-book/index(default).phtml',
|
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||||
'pages/search/index(default).phtml',
|
'pages/search/index(default).phtml',
|
||||||
'pages/help/hotkeys(default).phtml',
|
'pages/help/hotkeys(default).phtml',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class ListUiSharedPartialsContractTest extends TestCase
|
|||||||
public function testAppInitIncludesGlobalConfirmActionComponent(): void
|
public function testAppInitIncludesGlobalConfirmActionComponent(): void
|
||||||
{
|
{
|
||||||
$content = $this->readProjectFile('web/js/app-init.js');
|
$content = $this->readProjectFile('web/js/app-init.js');
|
||||||
$this->assertStringContainsString("import './components/app-confirm-actions.js';", $content);
|
$this->assertStringContainsString("import { initConfirmActions } from './components/app-confirm-actions.js';", $content);
|
||||||
|
$this->assertStringContainsString("runtime.register('confirm-actions', initConfirmActions", $content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ final class ModuleRegistryContractTest extends TestCase
|
|||||||
|
|
||||||
public function testAddressBookModuleLoadsWhenEnabled(): void
|
public function testAddressBookModuleLoadsWhenEnabled(): void
|
||||||
{
|
{
|
||||||
$registry = app(ModuleRegistry::class);
|
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||||
|
$registry = ModuleRegistry::boot($modulesDir, ['addressbook']);
|
||||||
|
|
||||||
// config/modules.php has addressbook enabled by default
|
|
||||||
self::assertTrue($registry->hasModule('addressbook'));
|
self::assertTrue($registry->hasModule('addressbook'));
|
||||||
self::assertNotEmpty($registry->getSearchResources());
|
self::assertNotEmpty($registry->getSearchResources());
|
||||||
self::assertNotEmpty($registry->getLayoutContextProviders());
|
self::assertNotEmpty($registry->getLayoutContextProviders());
|
||||||
@@ -32,8 +32,16 @@ final class ModuleRegistryContractTest extends TestCase
|
|||||||
{
|
{
|
||||||
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||||
|
|
||||||
// Boot a fresh registry with no modules enabled (simulates APP_ENABLED_MODULES='')
|
putenv('APP_ENABLED_MODULES=');
|
||||||
$registry = ModuleRegistry::boot($modulesDir, []);
|
try {
|
||||||
|
$enabledModules = ModuleRegistry::resolveEnabledModules([
|
||||||
|
'enabled_modules' => ['addressbook', 'bookmarks'],
|
||||||
|
]);
|
||||||
|
self::assertSame([], $enabledModules, 'APP_ENABLED_MODULES empty string must disable all modules.');
|
||||||
|
$registry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
||||||
|
} finally {
|
||||||
|
putenv('APP_ENABLED_MODULES');
|
||||||
|
}
|
||||||
|
|
||||||
self::assertSame([], $registry->getModules());
|
self::assertSame([], $registry->getModules());
|
||||||
self::assertSame([], $registry->getRoutes());
|
self::assertSame([], $registry->getRoutes());
|
||||||
@@ -54,14 +62,53 @@ final class ModuleRegistryContractTest extends TestCase
|
|||||||
$raw = require $manifestFile;
|
$raw = require $manifestFile;
|
||||||
self::assertIsArray($raw);
|
self::assertIsArray($raw);
|
||||||
self::assertSame('addressbook', $raw['id']);
|
self::assertSame('addressbook', $raw['id']);
|
||||||
// Permissions owned by Core PermissionService, not module
|
self::assertSame([[
|
||||||
self::assertSame([], $raw['permissions']);
|
'key' => 'address_book.view',
|
||||||
// Asset groups owned by config/assets.php, not module
|
'description' => 'Can view address book',
|
||||||
self::assertSame([], $raw['asset_groups']);
|
'active' => true,
|
||||||
|
'is_system' => true,
|
||||||
|
]], $raw['permissions']);
|
||||||
|
self::assertArrayHasKey('address-book', $raw['asset_groups']);
|
||||||
|
self::assertSame(
|
||||||
|
['modules/addressbook/css/pages/address-book-view.css'],
|
||||||
|
$raw['asset_groups']['address-book']
|
||||||
|
);
|
||||||
self::assertNotEmpty($raw['search_resources']);
|
self::assertNotEmpty($raw['search_resources']);
|
||||||
self::assertNotEmpty($raw['layout_context_providers']);
|
self::assertNotEmpty($raw['layout_context_providers']);
|
||||||
self::assertNotEmpty($raw['session_providers']);
|
self::assertNotEmpty($raw['session_providers']);
|
||||||
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
|
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('search.resource_item', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('user.edit.aside_action', $raw['ui_slots']);
|
||||||
|
|
||||||
|
$routes = is_array($raw['routes'] ?? null) ? $raw['routes'] : [];
|
||||||
|
$routeByPath = [];
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
if (!is_array($route)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$routeByPath[(string) ($route['path'] ?? '')] = (string) ($route['target'] ?? '');
|
||||||
|
}
|
||||||
|
self::assertSame('address-book', $routeByPath['address-book'] ?? null);
|
||||||
|
self::assertSame('address-book/data', $routeByPath['address-book/data'] ?? null);
|
||||||
|
self::assertSame('address-book', $routeByPath['addressbook'] ?? null);
|
||||||
|
self::assertSame('address-book', $routeByPath['adressbook'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookmarksModuleManifestIsValid(): void
|
||||||
|
{
|
||||||
|
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||||
|
$manifestFile = $modulesDir . '/bookmarks/module.php';
|
||||||
|
|
||||||
|
self::assertFileExists($manifestFile, 'Bookmarks module manifest must exist');
|
||||||
|
|
||||||
|
$raw = require $manifestFile;
|
||||||
|
self::assertIsArray($raw);
|
||||||
|
self::assertSame('bookmarks', $raw['id']);
|
||||||
|
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('topbar.right_item', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('layout.body_end_template', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('layout.head_style', $raw['ui_slots']);
|
||||||
|
self::assertArrayHasKey('runtime.component', $raw['ui_slots']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testModuleProviderClassesExist(): void
|
public function testModuleProviderClassesExist(): void
|
||||||
@@ -104,11 +151,11 @@ final class ModuleRegistryContractTest extends TestCase
|
|||||||
|
|
||||||
file_put_contents($fixturesDir . '/mod-a/module.php', '<?php return ' . var_export([
|
file_put_contents($fixturesDir . '/mod-a/module.php', '<?php return ' . var_export([
|
||||||
'id' => 'mod-a',
|
'id' => 'mod-a',
|
||||||
'permissions' => ['shared.perm'],
|
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared']],
|
||||||
], true) . ';');
|
], true) . ';');
|
||||||
file_put_contents($fixturesDir . '/mod-b/module.php', '<?php return ' . var_export([
|
file_put_contents($fixturesDir . '/mod-b/module.php', '<?php return ' . var_export([
|
||||||
'id' => 'mod-b',
|
'id' => 'mod-b',
|
||||||
'permissions' => ['shared.perm'],
|
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared duplicate']],
|
||||||
], true) . ';');
|
], true) . ';');
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|||||||
576
tests/Architecture/ModuleStructureContractTest.php
Normal file
576
tests/Architecture/ModuleStructureContractTest.php
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||||
|
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||||
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleManifest;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture test: enforces structural conventions for ALL modules under modules/.
|
||||||
|
*
|
||||||
|
* Every module directory must pass these checks. This ensures consistency
|
||||||
|
* when new modules are added and prevents structural drift.
|
||||||
|
*/
|
||||||
|
final class ModuleStructureContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
/** @var list<array{id: string, path: string, manifest: ModuleManifest, raw: array<string, mixed>}> */
|
||||||
|
private static array $modules = [];
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
$modulesDir = realpath(__DIR__ . '/../../modules');
|
||||||
|
if ($modulesDir === false || !is_dir($modulesDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($modulesDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestFile = $modulesDir . '/' . $entry . '/module.php';
|
||||||
|
if (!is_file($manifestFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = require $manifestFile;
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
|
||||||
|
self::$modules[] = [
|
||||||
|
'id' => $entry,
|
||||||
|
'path' => $modulesDir . '/' . $entry,
|
||||||
|
'manifest' => $manifest,
|
||||||
|
'raw' => $raw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Manifest structure ──────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testAtLeastOneModuleExists(): void
|
||||||
|
{
|
||||||
|
self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryModuleHasManifest(): void
|
||||||
|
{
|
||||||
|
$modulesDir = $this->projectRootPath() . '/modules';
|
||||||
|
if (!is_dir($modulesDir)) {
|
||||||
|
self::markTestSkipped('No modules/ directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = scandir($modulesDir) ?: [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::assertFileExists(
|
||||||
|
$modulesDir . '/' . $entry . '/module.php',
|
||||||
|
"Module directory '{$entry}' is missing module.php manifest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManifestIdMatchesDirectoryName(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
self::assertSame(
|
||||||
|
$module['id'],
|
||||||
|
$module['manifest']->id,
|
||||||
|
"Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManifestHasVersion(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^\d+\.\d+\.\d+$/',
|
||||||
|
$module['manifest']->version,
|
||||||
|
"Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManifestHasAllRequiredKeys(): void
|
||||||
|
{
|
||||||
|
$requiredKeys = [
|
||||||
|
'id', 'version', 'enabled_by_default', 'load_order',
|
||||||
|
'routes', 'public_paths', 'container_registrars',
|
||||||
|
'ui_slots', 'search_resources', 'asset_groups',
|
||||||
|
'scheduler_jobs', 'layout_context_providers',
|
||||||
|
'session_providers', 'permissions',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($requiredKeys as $key) {
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
$key,
|
||||||
|
$module['raw'],
|
||||||
|
"Module '{$module['id']}' manifest is missing required key '{$key}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File structure ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testModuleHasLibDirectory(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
self::assertDirectoryExists(
|
||||||
|
$module['path'] . '/lib',
|
||||||
|
"Module '{$module['id']}' must have a lib/ directory for PHP classes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Container registrars ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testContainerRegistrarClassesExist(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->containerRegistrars as $registrar) {
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($registrar),
|
||||||
|
"Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider contracts ──────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testSearchProviderClassesImplementContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->searchResources as $providerClass) {
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($providerClass),
|
||||||
|
"Module '{$module['id']}' search provider '{$providerClass}' class not found"
|
||||||
|
);
|
||||||
|
$provider = new $providerClass();
|
||||||
|
self::assertInstanceOf(
|
||||||
|
SearchResourceProvider::class,
|
||||||
|
$provider,
|
||||||
|
"Module '{$module['id']}' search provider must implement SearchResourceProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLayoutContextProviderClassesImplementContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->layoutContextProviders as $providerClass) {
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($providerClass),
|
||||||
|
"Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
|
||||||
|
);
|
||||||
|
$provider = new $providerClass();
|
||||||
|
self::assertInstanceOf(
|
||||||
|
LayoutContextProvider::class,
|
||||||
|
$provider,
|
||||||
|
"Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSessionProviderClassesImplementContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($providerClass),
|
||||||
|
"Module '{$module['id']}' session provider '{$providerClass}' class not found"
|
||||||
|
);
|
||||||
|
$provider = new $providerClass();
|
||||||
|
self::assertInstanceOf(
|
||||||
|
SessionProvider::class,
|
||||||
|
$provider,
|
||||||
|
"Module '{$module['id']}' session provider must implement SessionProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Authorization policies ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function testAuthorizationPolicyClassesImplementContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($policyClass),
|
||||||
|
"Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
|
||||||
|
);
|
||||||
|
self::assertTrue(
|
||||||
|
is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
|
||||||
|
|| in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
|
||||||
|
"Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Layout capabilities ─────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testLayoutCapabilitiesHaveNonEmptyKeysAndAbilities(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->layoutCapabilities as $uiKey => $ability) {
|
||||||
|
self::assertNotEmpty(
|
||||||
|
trim((string) $uiKey),
|
||||||
|
"Module '{$module['id']}' has empty layout_capabilities key"
|
||||||
|
);
|
||||||
|
self::assertNotEmpty(
|
||||||
|
trim((string) $ability),
|
||||||
|
"Module '{$module['id']}' layout_capabilities key '{$uiKey}' has empty ability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLayoutCapabilityAbilitiesMatchPolicySupports(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
if (empty($module['manifest']->layoutCapabilities)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all abilities supported by this module's policies
|
||||||
|
$supportedAbilities = [];
|
||||||
|
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||||
|
if (!class_exists($policyClass)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check each layout capability ability against this policy
|
||||||
|
$reflection = new \ReflectionClass($policyClass);
|
||||||
|
foreach ($reflection->getConstants() as $constValue) {
|
||||||
|
if (is_string($constValue)) {
|
||||||
|
$supportedAbilities[] = $constValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($module['manifest']->layoutCapabilities as $uiKey => $ability) {
|
||||||
|
self::assertContains(
|
||||||
|
$ability,
|
||||||
|
$supportedAbilities,
|
||||||
|
"Module '{$module['id']}' layout_capabilities['{$uiKey}'] references ability '{$ability}' "
|
||||||
|
. "but no module authorization policy declares it as a constant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Permissions contract ───────────────────────────────────────
|
||||||
|
|
||||||
|
public function testPermissionsUseObjectContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->permissions as $index => $permission) {
|
||||||
|
self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
|
||||||
|
self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
|
||||||
|
self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
|
||||||
|
self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
|
||||||
|
self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
|
||||||
|
self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scheduler jobs contract ────────────────────────────────────
|
||||||
|
|
||||||
|
public function testSchedulerJobsUseFullContractSchema(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->schedulerJobs as $index => $job) {
|
||||||
|
$required = [
|
||||||
|
'job_key',
|
||||||
|
'handler',
|
||||||
|
'label',
|
||||||
|
'description',
|
||||||
|
'default_enabled',
|
||||||
|
'default_timezone',
|
||||||
|
'default_schedule_type',
|
||||||
|
'default_schedule_interval',
|
||||||
|
'default_schedule_time',
|
||||||
|
'default_schedule_weekdays_csv',
|
||||||
|
'default_catchup_once',
|
||||||
|
'allowed_schedule_types',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($required as $requiredKey) {
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
$requiredKey,
|
||||||
|
$job,
|
||||||
|
"Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
|
||||||
|
self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
|
||||||
|
self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
|
||||||
|
self::assertContains(
|
||||||
|
(string) $job['default_schedule_type'],
|
||||||
|
['hourly', 'daily', 'weekly'],
|
||||||
|
"Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
|
||||||
|
);
|
||||||
|
|
||||||
|
$allowedTypes = array_values($job['allowed_schedule_types']);
|
||||||
|
self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
|
||||||
|
foreach ($allowedTypes as $allowedType) {
|
||||||
|
self::assertContains(
|
||||||
|
(string) $allowedType,
|
||||||
|
['hourly', 'daily', 'weekly'],
|
||||||
|
"Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI slots ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testUiSlotContributionsHaveRequiredKeys(): void
|
||||||
|
{
|
||||||
|
$slotKeyRequirements = [
|
||||||
|
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
|
||||||
|
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
|
||||||
|
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
|
||||||
|
'topbar.right_item' => ['key', 'template'],
|
||||||
|
'layout.body_end_template' => ['key', 'template'],
|
||||||
|
'layout.head_style' => ['key', 'path'],
|
||||||
|
'runtime.component' => ['key', 'script'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
|
||||||
|
if (!isset($slotKeyRequirements[$slotName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = $slotKeyRequirements[$slotName];
|
||||||
|
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||||
|
|
||||||
|
foreach ($contributionList as $idx => $contribution) {
|
||||||
|
if (!is_array($contribution)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($required as $requiredKey) {
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
$requiredKey,
|
||||||
|
$contribution,
|
||||||
|
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPanelTemplatesExist(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
$templateSlots = [
|
||||||
|
'aside.tab_panel' => 'panel_template',
|
||||||
|
'topbar.right_item' => 'template',
|
||||||
|
'layout.body_end_template' => 'template',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($templateSlots as $slotName => $templateKey) {
|
||||||
|
$contributions = $module['manifest']->uiSlots[$slotName] ?? [];
|
||||||
|
if (!is_array($contributions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($contributions as $contribution) {
|
||||||
|
if (!is_array($contribution)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$template = $contribution[$templateKey] ?? null;
|
||||||
|
if ($template === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::assertFileExists(
|
||||||
|
(string) $template,
|
||||||
|
"Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$template}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRuntimeComponentPhasesAreValid(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
$runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
|
||||||
|
if (!is_array($runtimeComponents)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($runtimeComponents as $index => $contribution) {
|
||||||
|
if (!is_array($contribution)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
|
||||||
|
self::assertContains(
|
||||||
|
$phase,
|
||||||
|
['early', 'default', 'late'],
|
||||||
|
"Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Routes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testRoutesHavePathAndTarget(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->routes as $idx => $route) {
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
'path',
|
||||||
|
$route,
|
||||||
|
"Module '{$module['id']}' route[{$idx}] is missing 'path'"
|
||||||
|
);
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
'target',
|
||||||
|
$route,
|
||||||
|
"Module '{$module['id']}' route[{$idx}] is missing 'target'"
|
||||||
|
);
|
||||||
|
self::assertNotEmpty(
|
||||||
|
trim((string) $route['path']),
|
||||||
|
"Module '{$module['id']}' route[{$idx}] has empty 'path'"
|
||||||
|
);
|
||||||
|
self::assertNotEmpty(
|
||||||
|
trim((string) $route['target']),
|
||||||
|
"Module '{$module['id']}' route[{$idx}] has empty 'target'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pages directory ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testModulesWithRoutesHavePagesDirectory(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
if (empty($module['manifest']->routes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::assertDirectoryExists(
|
||||||
|
$module['path'] . '/pages',
|
||||||
|
"Module '{$module['id']}' declares routes but has no pages/ directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ownership ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All PHP classes under modules/<id>/lib/ must use a MintyPHP\Module\<Name>\
|
||||||
|
* namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\.
|
||||||
|
*
|
||||||
|
* This prevents module code from masquerading as core code and ensures
|
||||||
|
* `grep MintyPHP\Module\` reliably finds all module-owned classes.
|
||||||
|
*/
|
||||||
|
public function testModuleClassesUseModuleNamespace(): void
|
||||||
|
{
|
||||||
|
$coreNamespacePrefixes = [
|
||||||
|
'MintyPHP\\Service\\',
|
||||||
|
'MintyPHP\\Repository\\',
|
||||||
|
'MintyPHP\\Support\\',
|
||||||
|
'MintyPHP\\Http\\',
|
||||||
|
'MintyPHP\\App\\Container\\',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
$libDir = $module['path'] . '/lib';
|
||||||
|
if (!is_dir($libDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->getExtension() !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($file->getPathname());
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
|
||||||
|
$namespace = $match[1];
|
||||||
|
foreach ($coreNamespacePrefixes as $corePrefix) {
|
||||||
|
self::assertFalse(
|
||||||
|
str_starts_with($namespace . '\\', $corePrefix),
|
||||||
|
"Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
|
||||||
|
. "Module classes must use MintyPHP\\Module\\* namespace."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No hardcoded Core ability imports in module pages ───────────
|
||||||
|
|
||||||
|
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
$pagesDir = $module['path'] . '/pages';
|
||||||
|
if (!is_dir($pagesDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($pagesDir)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($file->getPathname());
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'OperationsAuthorizationPolicy::ABILITY_',
|
||||||
|
$content,
|
||||||
|
"Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
|
||||||
|
. "Use the module's own AuthorizationPolicy instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ class OperationsAuthorizationPolicyTest extends TestCase
|
|||||||
{
|
{
|
||||||
$policy = new OperationsAuthorizationPolicy($this->createMock(PermissionService::class));
|
$policy = new OperationsAuthorizationPolicy($this->createMock(PermissionService::class));
|
||||||
|
|
||||||
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW));
|
|
||||||
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW));
|
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW));
|
||||||
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TENANTS_DELETE));
|
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TENANTS_DELETE));
|
||||||
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE));
|
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE));
|
||||||
@@ -40,7 +39,7 @@ class OperationsAuthorizationPolicyTest extends TestCase
|
|||||||
$permissionService->expects($this->never())->method('userHas');
|
$permissionService->expects($this->never())->method('userHas');
|
||||||
|
|
||||||
$policy = new OperationsAuthorizationPolicy($permissionService);
|
$policy = new OperationsAuthorizationPolicy($permissionService);
|
||||||
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
|
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, [
|
||||||
'actor_user_id' => 0,
|
'actor_user_id' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -83,31 +82,7 @@ class OperationsAuthorizationPolicyTest extends TestCase
|
|||||||
|
|
||||||
// --- authorize: allowIfHas (simple permission check) ---
|
// --- authorize: allowIfHas (simple permission check) ---
|
||||||
|
|
||||||
public function testAddressBookViewAllowedWithPermission(): void
|
// Address book ability tests moved to AddressBookAuthorizationPolicyTest (module-owned).
|
||||||
{
|
|
||||||
$permissionService = $this->permissionGatewayAllowing([
|
|
||||||
5 => [PermissionService::ADDRESS_BOOK_VIEW],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$policy = new OperationsAuthorizationPolicy($permissionService);
|
|
||||||
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
|
|
||||||
'actor_user_id' => 5,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertTrue($decision->isAllowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAddressBookViewDeniedWithoutPermission(): void
|
|
||||||
{
|
|
||||||
$permissionService = $this->permissionGatewayAllowing([5 => []]);
|
|
||||||
|
|
||||||
$policy = new OperationsAuthorizationPolicy($permissionService);
|
|
||||||
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
|
|
||||||
'actor_user_id' => 5,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertForbiddenDecision($decision);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testJobsViewAllowedWithPermission(): void
|
public function testJobsViewAllowedWithPermission(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Auth;
|
namespace MintyPHP\Tests\Service\Auth;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\Http\SessionStore;
|
use MintyPHP\Http\SessionStore;
|
||||||
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
|
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
|
||||||
use MintyPHP\Service\User\UserTenantContextService;
|
use MintyPHP\Service\User\UserTenantContextService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
|
DummyAuthSessionProvider::$populateCalls = 0;
|
||||||
|
DummyAuthSessionProvider::$clearCalls = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHydrateMarksNoActiveTenantWhenUserHasNoTenants(): void
|
public function testHydrateMarksNoActiveTenantWhenUserHasNoTenants(): void
|
||||||
@@ -21,25 +25,16 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
|
|
||||||
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
||||||
$userTenantContextService->method('getAvailableTenants')->willReturn([]);
|
$userTenantContextService->method('getAvailableTenants')->willReturn([]);
|
||||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
|
|
||||||
$userTenantContextService->expects($this->never())->method('getCurrentTenant');
|
$userTenantContextService->expects($this->never())->method('getCurrentTenant');
|
||||||
|
|
||||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
$service = $this->createService($userTenantContextService);
|
||||||
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
|
|
||||||
|
|
||||||
$service = new AuthSessionTenantContextService(
|
|
||||||
$userTenantContextService,
|
|
||||||
$bookmarkService,
|
|
||||||
new SessionStore()
|
|
||||||
);
|
|
||||||
|
|
||||||
$service->hydrateForUser(5);
|
$service->hydrateForUser(5);
|
||||||
|
|
||||||
$this->assertSame([], $_SESSION['available_tenants']);
|
$this->assertSame([], $_SESSION['available_tenants']);
|
||||||
$this->assertSame([], $_SESSION['available_departments_by_tenant']);
|
|
||||||
$this->assertSame(['groups' => [], 'ungrouped' => []], $_SESSION['user_bookmarks']);
|
|
||||||
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
|
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
|
||||||
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
|
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
|
||||||
|
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
|
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
|
||||||
@@ -48,30 +43,18 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
['id' => 7, 'uuid' => 'tenant-7'],
|
['id' => 7, 'uuid' => 'tenant-7'],
|
||||||
['id' => 8, 'uuid' => 'tenant-8'],
|
['id' => 8, 'uuid' => 'tenant-8'],
|
||||||
];
|
];
|
||||||
$availableDepartments = ['7' => [['id' => 11]]];
|
|
||||||
$bookmarks = ['groups' => [['id' => 1, 'name' => 'Dev', 'bookmarks' => []]], 'ungrouped' => []];
|
|
||||||
|
|
||||||
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
||||||
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
|
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
|
||||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn($availableDepartments);
|
|
||||||
$userTenantContextService->method('getCurrentTenant')->willReturn(null);
|
$userTenantContextService->method('getCurrentTenant')->willReturn(null);
|
||||||
|
|
||||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
$service = $this->createService($userTenantContextService);
|
||||||
$bookmarkService->method('listGroupedForUser')->willReturn($bookmarks);
|
|
||||||
|
|
||||||
$service = new AuthSessionTenantContextService(
|
|
||||||
$userTenantContextService,
|
|
||||||
$bookmarkService,
|
|
||||||
new SessionStore()
|
|
||||||
);
|
|
||||||
|
|
||||||
$service->hydrateForUser(5);
|
$service->hydrateForUser(5);
|
||||||
|
|
||||||
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
|
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
|
||||||
$this->assertSame($availableDepartments, $_SESSION['available_departments_by_tenant']);
|
|
||||||
$this->assertSame($bookmarks, $_SESSION['user_bookmarks']);
|
|
||||||
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
|
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
|
||||||
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
|
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
|
||||||
|
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void
|
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void
|
||||||
@@ -87,17 +70,9 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
|
|
||||||
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
||||||
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
|
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
|
||||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
|
|
||||||
$userTenantContextService->method('getCurrentTenant')->willReturn($availableTenants[0]);
|
$userTenantContextService->method('getCurrentTenant')->willReturn($availableTenants[0]);
|
||||||
|
|
||||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
$service = $this->createService($userTenantContextService);
|
||||||
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
|
|
||||||
|
|
||||||
$service = new AuthSessionTenantContextService(
|
|
||||||
$userTenantContextService,
|
|
||||||
$bookmarkService,
|
|
||||||
new SessionStore()
|
|
||||||
);
|
|
||||||
|
|
||||||
$service->hydrateForUser(10);
|
$service->hydrateForUser(10);
|
||||||
|
|
||||||
@@ -105,4 +80,73 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
$this->assertSame('dark', $tenant['default_theme'] ?? null);
|
$this->assertSame('dark', $tenant['default_theme'] ?? null);
|
||||||
$this->assertSame('0', $tenant['allow_user_theme'] ?? null);
|
$this->assertSame('0', $tenant['allow_user_theme'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testHydrateAndClearInvokeConfiguredModuleSessionProviders(): void
|
||||||
|
{
|
||||||
|
$fixturesDir = sys_get_temp_dir() . '/auth-session-provider-test-' . uniqid();
|
||||||
|
mkdir($fixturesDir . '/mod-session', 0777, true);
|
||||||
|
file_put_contents(
|
||||||
|
$fixturesDir . '/mod-session/module.php',
|
||||||
|
'<?php return ' . var_export([
|
||||||
|
'id' => 'mod-session',
|
||||||
|
'session_providers' => [DummyAuthSessionProvider::class],
|
||||||
|
], true) . ';'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$registry = ModuleRegistry::boot($fixturesDir, ['mod-session']);
|
||||||
|
|
||||||
|
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
||||||
|
$userTenantContextService->method('getAvailableTenants')->willReturn([['id' => 7, 'uuid' => 'tenant-7']]);
|
||||||
|
$userTenantContextService->method('getCurrentTenant')->willReturn(['id' => 7, 'uuid' => 'tenant-7']);
|
||||||
|
|
||||||
|
$service = $this->createService($userTenantContextService, $registry);
|
||||||
|
$_SESSION['user'] = ['id' => 42];
|
||||||
|
|
||||||
|
$service->hydrateForUser(42);
|
||||||
|
$this->assertSame(1, DummyAuthSessionProvider::$populateCalls);
|
||||||
|
$this->assertSame(42, (int) ($_SESSION['dummy_provider_user_id'] ?? 0));
|
||||||
|
|
||||||
|
$service->clearModuleSessionData();
|
||||||
|
$this->assertSame(1, DummyAuthSessionProvider::$clearCalls);
|
||||||
|
$this->assertArrayNotHasKey('dummy_provider_user_id', $_SESSION);
|
||||||
|
} finally {
|
||||||
|
@unlink($fixturesDir . '/mod-session/module.php');
|
||||||
|
@rmdir($fixturesDir . '/mod-session');
|
||||||
|
@rmdir($fixturesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(
|
||||||
|
UserTenantContextService $userTenantContextService,
|
||||||
|
?ModuleRegistry $registry = null
|
||||||
|
): AuthSessionTenantContextService {
|
||||||
|
$registry ??= ModuleRegistry::boot(sys_get_temp_dir() . '/module-registry-empty-' . uniqid(), []);
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
|
||||||
|
|
||||||
|
return new AuthSessionTenantContextService(
|
||||||
|
$userTenantContextService,
|
||||||
|
new SessionStore(),
|
||||||
|
$container
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DummyAuthSessionProvider implements SessionProvider
|
||||||
|
{
|
||||||
|
public static int $populateCalls = 0;
|
||||||
|
public static int $clearCalls = 0;
|
||||||
|
|
||||||
|
public function populate(array $user, AppContainer $container): void
|
||||||
|
{
|
||||||
|
self::$populateCalls++;
|
||||||
|
$_SESSION['dummy_provider_user_id'] = (int) ($user['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
self::$clearCalls++;
|
||||||
|
unset($_SESSION['dummy_provider_user_id']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
tests/Service/Scheduler/ScheduledJobRegistryTest.php
Normal file
130
tests/Service/Scheduler/ScheduledJobRegistryTest.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Service\Scheduler;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\Service\Audit\SystemAuditService;
|
||||||
|
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
||||||
|
use MintyPHP\Service\Scheduler\ScheduledJobRegistry;
|
||||||
|
use MintyPHP\Service\User\UserLifecycleService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScheduledJobRegistryTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $fixturesDir = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->fixturesDir = sys_get_temp_dir() . '/scheduler-module-registry-' . uniqid();
|
||||||
|
mkdir($this->fixturesDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDir($this->fixturesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModuleJobAppearsInDefinitionsAndIsExecutable(): void
|
||||||
|
{
|
||||||
|
$moduleRegistry = $this->createRegistryWithModuleJob();
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
||||||
|
$container->set(
|
||||||
|
DummyModuleScheduledJobHandler::class,
|
||||||
|
static fn (): DummyModuleScheduledJobHandler => new DummyModuleScheduledJobHandler()
|
||||||
|
);
|
||||||
|
|
||||||
|
$registry = new ScheduledJobRegistry(
|
||||||
|
$this->createMock(UserLifecycleService::class),
|
||||||
|
$this->createMock(SystemAuditService::class),
|
||||||
|
$container
|
||||||
|
);
|
||||||
|
|
||||||
|
$definitions = $registry->definitions();
|
||||||
|
self::assertArrayHasKey('addressbook.sync', $definitions);
|
||||||
|
self::assertSame('Address book sync', $definitions['addressbook.sync']['label'] ?? null);
|
||||||
|
|
||||||
|
$execution = $registry->execute('addressbook.sync', 123);
|
||||||
|
self::assertSame('success', $execution['status']);
|
||||||
|
self::assertSame(['actor' => 123], $execution['result']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRegistryWithModuleJob(): ModuleRegistry
|
||||||
|
{
|
||||||
|
mkdir($this->fixturesDir . '/mod-job', 0777, true);
|
||||||
|
file_put_contents(
|
||||||
|
$this->fixturesDir . '/mod-job/module.php',
|
||||||
|
'<?php return ' . var_export([
|
||||||
|
'id' => 'mod-job',
|
||||||
|
'scheduler_jobs' => [[
|
||||||
|
'job_key' => 'addressbook.sync',
|
||||||
|
'handler' => DummyModuleScheduledJobHandler::class,
|
||||||
|
'label' => 'Address book sync',
|
||||||
|
'description' => 'Sync job',
|
||||||
|
'default_enabled' => true,
|
||||||
|
'default_timezone' => 'UTC',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '03:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => true,
|
||||||
|
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||||
|
]],
|
||||||
|
], true) . ';'
|
||||||
|
);
|
||||||
|
|
||||||
|
return ModuleRegistry::boot($this->fixturesDir, ['mod-job']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DummyModuleScheduledJobHandler implements ScheduledJobHandlerInterface
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => 'dummy',
|
||||||
|
'description' => 'dummy',
|
||||||
|
'default_enabled' => 1,
|
||||||
|
'default_timezone' => 'UTC',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '00:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => 1,
|
||||||
|
'allowed_schedule_types' => ['daily'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(?int $actorUserId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'result' => ['actor' => $actorUserId],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,97 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
class ScheduledJobServiceTest extends TestCase
|
class ScheduledJobServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
|
public function testEnsureSystemJobsCreatesModuleJobDefinition(): void
|
||||||
|
{
|
||||||
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||||
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
||||||
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
||||||
|
$calculator = new ScheduleCalculator();
|
||||||
|
|
||||||
|
$registry->expects($this->once())->method('definitions')->willReturn([
|
||||||
|
'addressbook.sync' => [
|
||||||
|
'label' => 'Addressbook Sync',
|
||||||
|
'description' => 'Syncs address book data',
|
||||||
|
'default_enabled' => 1,
|
||||||
|
'default_timezone' => 'UTC',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '03:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => 1,
|
||||||
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$jobRepository->expects($this->once())
|
||||||
|
->method('findByKey')
|
||||||
|
->with('addressbook.sync')
|
||||||
|
->willReturn(null);
|
||||||
|
$jobRepository->expects($this->once())
|
||||||
|
->method('create')
|
||||||
|
->with($this->callback(static function (array $job): bool {
|
||||||
|
return ($job['job_key'] ?? null) === 'addressbook.sync'
|
||||||
|
&& ($job['label'] ?? null) === 'Addressbook Sync'
|
||||||
|
&& ($job['timezone'] ?? null) === 'UTC'
|
||||||
|
&& ($job['schedule_type'] ?? null) === 'daily';
|
||||||
|
}))
|
||||||
|
->willReturn(99);
|
||||||
|
$jobRepository->expects($this->never())->method('updateJobMeta');
|
||||||
|
|
||||||
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
||||||
|
$service->ensureSystemJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnsureSystemJobsUpdatesExistingModuleJobMetadata(): void
|
||||||
|
{
|
||||||
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||||
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
||||||
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
||||||
|
$calculator = new ScheduleCalculator();
|
||||||
|
|
||||||
|
$registry->expects($this->once())->method('definitions')->willReturn([
|
||||||
|
'addressbook.sync' => [
|
||||||
|
'label' => 'Addressbook Sync',
|
||||||
|
'description' => 'Syncs address book data',
|
||||||
|
'default_enabled' => 1,
|
||||||
|
'default_timezone' => 'UTC',
|
||||||
|
'default_schedule_type' => 'daily',
|
||||||
|
'default_schedule_interval' => 1,
|
||||||
|
'default_schedule_time' => '03:00',
|
||||||
|
'default_schedule_weekdays_csv' => null,
|
||||||
|
'default_catchup_once' => 1,
|
||||||
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$jobRepository->expects($this->once())
|
||||||
|
->method('findByKey')
|
||||||
|
->with('addressbook.sync')
|
||||||
|
->willReturn([
|
||||||
|
'id' => 12,
|
||||||
|
'job_key' => 'addressbook.sync',
|
||||||
|
'label' => 'Old Label',
|
||||||
|
'description' => 'Old description',
|
||||||
|
'enabled' => 1,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'schedule_type' => 'daily',
|
||||||
|
'schedule_interval' => 1,
|
||||||
|
'schedule_time' => '03:00',
|
||||||
|
'schedule_weekdays_csv' => null,
|
||||||
|
'catchup_once' => 1,
|
||||||
|
'next_run_at' => '2026-01-01 03:00:00',
|
||||||
|
]);
|
||||||
|
$jobRepository->expects($this->once())
|
||||||
|
->method('updateJobMeta')
|
||||||
|
->with(12, $this->callback(static function (array $job): bool {
|
||||||
|
return ($job['label'] ?? null) === 'Addressbook Sync'
|
||||||
|
&& ($job['description'] ?? null) === 'Syncs address book data';
|
||||||
|
}))
|
||||||
|
->willReturn(true);
|
||||||
|
$jobRepository->expects($this->never())->method('create');
|
||||||
|
|
||||||
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
||||||
|
$service->ensureSystemJobs();
|
||||||
|
}
|
||||||
|
|
||||||
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
|
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
|
||||||
{
|
{
|
||||||
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||||
|
|||||||
37
tests/Support/AppContainerIsolationTrait.php
Normal file
37
tests/Support/AppContainerIsolationTrait.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Support;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
|
||||||
|
trait AppContainerIsolationTrait
|
||||||
|
{
|
||||||
|
private mixed $appContainerIsolationPrevious = null;
|
||||||
|
private bool $appContainerIsolationActive = false;
|
||||||
|
|
||||||
|
protected function pushAppContainer(AppContainer $container): void
|
||||||
|
{
|
||||||
|
if (!$this->appContainerIsolationActive) {
|
||||||
|
$this->appContainerIsolationPrevious = $GLOBALS['minty_app_container'] ?? null;
|
||||||
|
$this->appContainerIsolationActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$GLOBALS['minty_app_container'] = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function restoreAppContainer(): void
|
||||||
|
{
|
||||||
|
if (!$this->appContainerIsolationActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->appContainerIsolationPrevious instanceof AppContainer) {
|
||||||
|
$GLOBALS['minty_app_container'] = $this->appContainerIsolationPrevious;
|
||||||
|
} else {
|
||||||
|
unset($GLOBALS['minty_app_container']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->appContainerIsolationPrevious = null;
|
||||||
|
$this->appContainerIsolationActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/Support/AppContainerIsolationTraitTest.php
Normal file
70
tests/Support/AppContainerIsolationTraitTest.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Support;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AppContainerIsolationTraitTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPushAndRestoreRoundTripKeepsOriginalGlobalContainer(): void
|
||||||
|
{
|
||||||
|
$original = $GLOBALS['minty_app_container'] ?? null;
|
||||||
|
self::assertInstanceOf(AppContainer::class, $original);
|
||||||
|
|
||||||
|
$harness = new class () {
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
|
public function push(AppContainer $container): void
|
||||||
|
{
|
||||||
|
$this->pushAppContainer($container);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(): void
|
||||||
|
{
|
||||||
|
$this->restoreAppContainer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$replacement = new AppContainer();
|
||||||
|
$harness->push($replacement);
|
||||||
|
|
||||||
|
self::assertSame($replacement, $GLOBALS['minty_app_container'] ?? null);
|
||||||
|
|
||||||
|
$harness->restore();
|
||||||
|
|
||||||
|
self::assertSame($original, $GLOBALS['minty_app_container'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiplePushesRestoreToInitialContainer(): void
|
||||||
|
{
|
||||||
|
$original = $GLOBALS['minty_app_container'] ?? null;
|
||||||
|
self::assertInstanceOf(AppContainer::class, $original);
|
||||||
|
|
||||||
|
$harness = new class () {
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
|
public function push(AppContainer $container): void
|
||||||
|
{
|
||||||
|
$this->pushAppContainer($container);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(): void
|
||||||
|
{
|
||||||
|
$this->restoreAppContainer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$first = new AppContainer();
|
||||||
|
$second = new AppContainer();
|
||||||
|
|
||||||
|
$harness->push($first);
|
||||||
|
$harness->push($second);
|
||||||
|
|
||||||
|
self::assertSame($second, $GLOBALS['minty_app_container'] ?? null);
|
||||||
|
|
||||||
|
$harness->restore();
|
||||||
|
|
||||||
|
self::assertSame($original, $GLOBALS['minty_app_container'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ namespace MintyPHP\Tests\Support;
|
|||||||
use MintyPHP\App\AppContainer;
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\Service\Settings\SettingCacheService;
|
use MintyPHP\Service\Settings\SettingCacheService;
|
||||||
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
||||||
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ThemeResolutionTest extends TestCase
|
class ThemeResolutionTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
private AppContainer $container;
|
private AppContainer $container;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -23,11 +26,12 @@ class ThemeResolutionTest extends TestCase
|
|||||||
|
|
||||||
$this->setAppSettings([]);
|
$this->setAppSettings([]);
|
||||||
|
|
||||||
$GLOBALS['minty_app_container'] = $this->container;
|
$this->pushAppContainer($this->container);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
$this->restoreAppContainer();
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,34 @@ namespace MintyPHP\Tests\Unit\Module;
|
|||||||
|
|
||||||
use MintyPHP\App\AppContainer;
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider;
|
use MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider;
|
||||||
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies that the AddressBookLayoutProvider correctly contributes
|
* Verifies that the AddressBookLayoutProvider correctly contributes
|
||||||
* the 'addressBook' key to the layout context.
|
* the 'addressbook.nav' key to the layout context.
|
||||||
*/
|
*/
|
||||||
final class LayoutContextProviderTest extends TestCase
|
final class LayoutContextProviderTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
// Guard against other tests overwriting the global container
|
// Guard against other tests overwriting the global container
|
||||||
if (!($GLOBALS['minty_app_container'] ?? null) instanceof AppContainer
|
if (!($GLOBALS['minty_app_container'] ?? null) instanceof AppContainer
|
||||||
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)) {
|
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)
|
||||||
|
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\Service\Access\UiAccessService::class)) {
|
||||||
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
|
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
|
||||||
|
$this->pushAppContainer($container);
|
||||||
setAppContainer($container);
|
setAppContainer($container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->restoreAppContainer();
|
||||||
|
}
|
||||||
|
|
||||||
public function testProviderReturnsAddressBookKey(): void
|
public function testProviderReturnsAddressBookKey(): void
|
||||||
{
|
{
|
||||||
$provider = new AddressBookLayoutProvider();
|
$provider = new AddressBookLayoutProvider();
|
||||||
@@ -38,8 +48,8 @@ final class LayoutContextProviderTest extends TestCase
|
|||||||
|
|
||||||
$result = $provider->provide($session, $container);
|
$result = $provider->provide($session, $container);
|
||||||
|
|
||||||
self::assertArrayHasKey('addressBook', $result);
|
self::assertArrayHasKey('addressbook.nav', $result);
|
||||||
$ab = $result['addressBook'];
|
$ab = $result['addressbook.nav'];
|
||||||
self::assertArrayHasKey('url', $ab);
|
self::assertArrayHasKey('url', $ab);
|
||||||
self::assertArrayHasKey('activeSearch', $ab);
|
self::assertArrayHasKey('activeSearch', $ab);
|
||||||
self::assertArrayHasKey('activeTenants', $ab);
|
self::assertArrayHasKey('activeTenants', $ab);
|
||||||
@@ -57,18 +67,17 @@ final class LayoutContextProviderTest extends TestCase
|
|||||||
|
|
||||||
$result = $provider->provide([], $container);
|
$result = $provider->provide([], $container);
|
||||||
|
|
||||||
self::assertArrayHasKey('addressBook', $result);
|
self::assertArrayHasKey('addressbook.nav', $result);
|
||||||
self::assertSame([], $result['addressBook']['peopleGroups']);
|
self::assertSame([], $result['addressbook.nav']['peopleGroups']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testProviderIsIncludedInLayoutNavWhenModuleActive(): void
|
public function testProviderIsIncludedInLayoutNavWhenModuleActive(): void
|
||||||
{
|
{
|
||||||
// When the module is active, appBuildLayoutNavContext() should include
|
// When the module is active, appBuildLayoutNavContext() should include
|
||||||
// addressBook via the provider loop
|
// addressbook.nav via the provider loop
|
||||||
$layoutAuth = app(\MintyPHP\Service\Access\UiAccessService::class)->layoutCapabilities(0);
|
$layoutNav = appBuildLayoutNavContext([], [], []);
|
||||||
$layoutNav = appBuildLayoutNavContext($layoutAuth, [], []);
|
|
||||||
|
|
||||||
self::assertArrayHasKey('addressBook', $layoutNav,
|
self::assertArrayHasKey('addressbook.nav', $layoutNav,
|
||||||
'addressBook key must be present in layoutNav when module is active');
|
'addressbook.nav key must be present in layoutNav when module is active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
tests/Unit/Module/LayoutNavProviderGuardTest.php
Normal file
110
tests/Unit/Module/LayoutNavProviderGuardTest.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Unit\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class LayoutNavProviderGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
|
private string $fixturesDir = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->fixturesDir = sys_get_temp_dir() . '/layout-provider-guard-' . uniqid();
|
||||||
|
mkdir($this->fixturesDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDir($this->fixturesDir);
|
||||||
|
$this->restoreAppContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReservedLayoutKeyIsRejected(): void
|
||||||
|
{
|
||||||
|
$registry = $this->createRegistryWithProvider(DummyReservedLayoutProvider::class);
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
|
||||||
|
$this->pushAppContainer($container);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage("must not override reserved layout key 'moduleSlots'");
|
||||||
|
|
||||||
|
appBuildLayoutNavContext([], [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNamespacedLayoutProviderKeyIsAccepted(): void
|
||||||
|
{
|
||||||
|
$registry = $this->createRegistryWithProvider(DummyNamespacedLayoutProvider::class);
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
|
||||||
|
$this->pushAppContainer($container);
|
||||||
|
|
||||||
|
$layoutNav = appBuildLayoutNavContext([], [], []);
|
||||||
|
|
||||||
|
self::assertSame('ok', $layoutNav['dummy.value'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRegistryWithProvider(string $providerClass): ModuleRegistry
|
||||||
|
{
|
||||||
|
$moduleDir = $this->fixturesDir . '/dummy-module';
|
||||||
|
mkdir($moduleDir, 0777, true);
|
||||||
|
file_put_contents(
|
||||||
|
$moduleDir . '/module.php',
|
||||||
|
'<?php return ' . var_export([
|
||||||
|
'id' => 'dummy-module',
|
||||||
|
'layout_context_providers' => [$providerClass],
|
||||||
|
], true) . ';'
|
||||||
|
);
|
||||||
|
|
||||||
|
return ModuleRegistry::boot($this->fixturesDir, ['dummy-module']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DummyReservedLayoutProvider implements LayoutContextProvider
|
||||||
|
{
|
||||||
|
public function provide(array $session, AppContainer $container): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'moduleSlots' => ['bad' => true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DummyNamespacedLayoutProvider implements LayoutContextProvider
|
||||||
|
{
|
||||||
|
public function provide(array $session, AppContainer $container): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'dummy.value' => 'ok',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace MintyPHP\Tests\Unit\Module;
|
|||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider;
|
use MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider;
|
||||||
use MintyPHP\Support\SearchConfig;
|
use MintyPHP\Support\SearchConfig;
|
||||||
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,18 +13,15 @@ use PHPUnit\Framework\TestCase;
|
|||||||
*/
|
*/
|
||||||
final class SearchProviderCollectionTest extends TestCase
|
final class SearchProviderCollectionTest extends TestCase
|
||||||
{
|
{
|
||||||
private static ?\MintyPHP\App\AppContainer $originalContainer = null;
|
use AppContainerIsolationTrait;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
// Guard against other tests overwriting the global container (e.g. ThemeResolutionTest)
|
|
||||||
if (self::$originalContainer === null) {
|
|
||||||
self::$originalContainer = $GLOBALS['minty_app_container'] ?? null;
|
|
||||||
}
|
|
||||||
if (!($GLOBALS['minty_app_container'] ?? null) instanceof \MintyPHP\App\AppContainer
|
if (!($GLOBALS['minty_app_container'] ?? null) instanceof \MintyPHP\App\AppContainer
|
||||||
|| !($GLOBALS['minty_app_container'])->has(ModuleRegistry::class)) {
|
|| !($GLOBALS['minty_app_container'])->has(ModuleRegistry::class)) {
|
||||||
// Restore the bootstrap container that has the module registry
|
// Restore the bootstrap container that has the module registry
|
||||||
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
|
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
|
||||||
|
$this->pushAppContainer($container);
|
||||||
setAppContainer($container);
|
setAppContainer($container);
|
||||||
}
|
}
|
||||||
SearchConfig::resetModuleProviders();
|
SearchConfig::resetModuleProviders();
|
||||||
@@ -31,6 +29,7 @@ final class SearchProviderCollectionTest extends TestCase
|
|||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
$this->restoreAppContainer();
|
||||||
SearchConfig::resetModuleProviders();
|
SearchConfig::resetModuleProviders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
tests/Unit/Module/SessionProviderTest.php
Normal file
95
tests/Unit/Module/SessionProviderTest.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Unit\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider
|
||||||
|
*/
|
||||||
|
final class SessionProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a real AppContainer that returns null for any get() call.
|
||||||
|
* AppContainer is final and cannot be mocked.
|
||||||
|
*/
|
||||||
|
private function emptyContainer(): AppContainer
|
||||||
|
{
|
||||||
|
return new AppContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImplementsSessionProviderContract(): void
|
||||||
|
{
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
self::assertInstanceOf(SessionProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPopulateWithInvalidUserClearsSessionKey(): void
|
||||||
|
{
|
||||||
|
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
|
||||||
|
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
$provider->populate(['id' => 0], $this->emptyContainer());
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPopulateWithMissingUserIdClearsSessionKey(): void
|
||||||
|
{
|
||||||
|
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
|
||||||
|
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
$provider->populate([], $this->emptyContainer());
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
|
||||||
|
{
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
// Container has no bindings → get() will throw, but provider guards with instanceof check
|
||||||
|
// The provider should handle this gracefully (no session key set)
|
||||||
|
try {
|
||||||
|
$provider->populate(['id' => 42], $this->emptyContainer());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Provider may throw if container has no binding — that's acceptable;
|
||||||
|
// in production, bindings are always registered before providers run.
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClearRemovesSessionKey(): void
|
||||||
|
{
|
||||||
|
$_SESSION['available_departments_by_tenant'] = [
|
||||||
|
['tenant' => ['uuid' => 'abc'], 'departments' => []],
|
||||||
|
];
|
||||||
|
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
$provider->clear();
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClearIsIdempotent(): void
|
||||||
|
{
|
||||||
|
// Key doesn't exist — clear() should not throw
|
||||||
|
$provider = new AddressBookSessionProvider();
|
||||||
|
$provider->clear();
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,15 @@
|
|||||||
3. MUST alle JS-`src` in `pages/**` und `templates/**` ueber `assetVersion('...js...')` laden.
|
3. MUST alle JS-`src` in `pages/**` und `templates/**` ueber `assetVersion('...js...')` laden.
|
||||||
4. MUST NOT inline `<script type="module">...</script>` in `pages/**` oder `templates/**` verwenden.
|
4. MUST NOT inline `<script type="module">...</script>` in `pages/**` oder `templates/**` verwenden.
|
||||||
|
|
||||||
|
## Component Lifecycle Runtime
|
||||||
|
|
||||||
|
1. MUST migrierte Komponenten ueber `init(root, config)` + `destroy()` standardisieren.
|
||||||
|
2. MUST NOT migrierte Komponenten per `DOMContentLoaded`/Module-Sideeffect selbst auto-initialisieren.
|
||||||
|
3. MUST Listener, Timer und Observer, die in `init(...)` entstehen, in `destroy()` wieder abbauen.
|
||||||
|
4. MUST Runtime-Konfiguration ueber Page-Config (`readPageConfig(...)`) beziehen, keine verteilten Ad-hoc-Globals.
|
||||||
|
5. MUST host-gebundene UI-Komponenten ueber explizite `data-app-component`-Hosts im Markup binden.
|
||||||
|
6. Global-Utilities ohne natuerliches Host-Element MAY via Runtime `scope: 'global'` registriert werden, MUESSEN aber denselben Lifecycle-Contract inkl. `destroy()` einhalten.
|
||||||
|
|
||||||
## Shared Partials
|
## Shared Partials
|
||||||
|
|
||||||
1. MUST Listen-Titlebar ueber `templates/partials/app-list-titlebar.phtml` rendern.
|
1. MUST Listen-Titlebar ueber `templates/partials/app-list-titlebar.phtml` rendern.
|
||||||
|
|||||||
@@ -32,16 +32,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
dialog[data-app-confirm-dialog] footer {
|
dialog[data-app-confirm-dialog] footer {
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog[data-app-confirm-dialog] footer button {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog[data-app-confirm-dialog][data-confirm-variant="danger"] > article {
|
dialog[data-app-confirm-dialog][data-confirm-variant="danger"] > article {
|
||||||
border-top: 3px solid var(--app-del-color, #b42318);
|
border-top: 3px solid var(--app-del-color, #b42318);
|
||||||
}
|
}
|
||||||
@@ -49,11 +42,4 @@
|
|||||||
dialog[data-app-confirm-dialog][data-confirm-variant="warning"] > article {
|
dialog[data-app-confirm-dialog][data-confirm-variant="warning"] > article {
|
||||||
border-top: 3px solid var(--app-warning-color, #b54708);
|
border-top: 3px solid var(--app-warning-color, #b54708);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
dialog[data-app-confirm-dialog] footer {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
75
web/css/components/app-dialog.css
Normal file
75
web/css/components/app-dialog.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* app-dialog.css
|
||||||
|
* Base dialog styles shared by all dialog variants (confirm, bookmark, session-warning).
|
||||||
|
* Handles footer layout, button sizing, header close button, and mobile fallback.
|
||||||
|
* Variant-specific styles live in their own component files (app-confirm-dialog.css, etc.).
|
||||||
|
*/
|
||||||
|
@layer components {
|
||||||
|
/* ── Footer layout ───────────────────────────────────────────────────── */
|
||||||
|
dialog > article > footer,
|
||||||
|
dialog > article form > footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer buttons: auto-width, no margin ───────────────────────────── */
|
||||||
|
dialog > article > footer [role="button"],
|
||||||
|
dialog > article > footer button,
|
||||||
|
dialog > article form > footer [role="button"],
|
||||||
|
dialog > article form > footer button {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header: title flush, close button positioned ────────────────────── */
|
||||||
|
dialog > article > header > * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog > article > header .close,
|
||||||
|
dialog > article > header :is(a, button)[rel="prev"] {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: var(--app-spacing);
|
||||||
|
padding: 0;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Close icon ──────────────────────────────────────────────────────── */
|
||||||
|
dialog > article .close,
|
||||||
|
dialog > article :is(a, button)[rel="prev"] {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-top: calc(var(--app-spacing) * -1);
|
||||||
|
margin-bottom: var(--app-spacing);
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background-image: var(--app-icon-close);
|
||||||
|
background-position: center;
|
||||||
|
background-size: auto 1rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-color: transparent;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity var(--app-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog > article .close:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus),
|
||||||
|
dialog > article :is(a, button)[rel="prev"]:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile: stack buttons vertically ────────────────────────────────── */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
dialog > article > footer,
|
||||||
|
dialog > article form > footer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog > article > footer button,
|
||||||
|
dialog > article form > footer button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,36 @@
|
|||||||
.page-layout {
|
@layer layout {
|
||||||
display: flex;
|
.page-layout {
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
min-height: 100dvh;
|
flex-direction: column;
|
||||||
}
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--app-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header .app-brand img {
|
.page-header .app-brand img {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header nav ul {
|
.page-header nav ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2769,10 +2769,6 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog > article > footer button {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi::before,
|
.bi::before,
|
||||||
[class^="bi-"]::before,
|
[class^="bi-"]::before,
|
||||||
[class*=" bi-"]::before {
|
[class*=" bi-"]::before {
|
||||||
@@ -2791,68 +2787,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog > article > header > * {
|
/* Dialog header/footer/close styles → web/css/components/app-dialog.css */
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > article > header .close,
|
|
||||||
dialog > article > header :is(a, button)[rel="prev"] {
|
|
||||||
margin: 0;
|
|
||||||
margin-left: var(--app-spacing);
|
|
||||||
padding: 0;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > article > footer {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > article > footer [role="button"],
|
|
||||||
dialog > article > footer button {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > article > footer [role="button"]:not(:first-of-type),
|
|
||||||
dialog > article > footer button:not(:first-of-type) {
|
|
||||||
margin-left: calc(var(--app-spacing) * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > article .close,
|
|
||||||
dialog > article :is(a, button)[rel="prev"] {
|
|
||||||
display: block;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
margin-top: calc(var(--app-spacing) * -1);
|
|
||||||
margin-bottom: var(--app-spacing);
|
|
||||||
margin-left: auto;
|
|
||||||
border: none;
|
|
||||||
background-image: var(--app-icon-close);
|
|
||||||
background-position: center;
|
|
||||||
background-size: auto 1rem;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-color: transparent;
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity var(--app-transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog
|
|
||||||
> article
|
|
||||||
.close:is(
|
|
||||||
[aria-current]:not([aria-current="false"]),
|
|
||||||
:hover,
|
|
||||||
:active,
|
|
||||||
:focus
|
|
||||||
),
|
|
||||||
dialog
|
|
||||||
> article
|
|
||||||
:is(a, button)[rel="prev"]:is(
|
|
||||||
[aria-current]:not([aria-current="false"]),
|
|
||||||
:hover,
|
|
||||||
:active,
|
|
||||||
:focus
|
|
||||||
) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog:not([open]),
|
dialog:not([open]),
|
||||||
dialog[open="false"] {
|
dialog[open="false"] {
|
||||||
|
|||||||
@@ -287,263 +287,4 @@
|
|||||||
margin-bottom: var(--app-spacing);
|
margin-bottom: var(--app-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar .app-sidebar-bookmark-item > a > i {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-left: var(--app-spacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--app-muted-color);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu {
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 5px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
summary:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary {
|
|
||||||
list-style: none;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--app-muted-color);
|
|
||||||
padding: 0.25rem;
|
|
||||||
min-width: 1.5rem;
|
|
||||||
min-height: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition:
|
|
||||||
color 120ms ease,
|
|
||||||
background 120ms ease,
|
|
||||||
opacity 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-item:hover
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-item:focus-within
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-group-header:hover
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-group-header:focus-within
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu[open]
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary
|
|
||||||
i {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu[open]
|
|
||||||
> summary,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary:hover,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary:focus-visible {
|
|
||||||
color: var(--app-contrast);
|
|
||||||
background: color-mix(in srgb, var(--app-contrast) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none), (pointer: coarse) {
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu
|
|
||||||
> summary {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
inset-block-start: calc(100% + 4px);
|
|
||||||
min-width: 10rem;
|
|
||||||
max-width: calc(220px - 2 * var(--app-spacing));
|
|
||||||
border: 1px solid var(--app-border);
|
|
||||||
border-radius: var(--app-border-radius);
|
|
||||||
background: var(--app-card-bg, var(--app-background-color));
|
|
||||||
box-shadow:
|
|
||||||
0 4px 16px rgba(0, 0, 0, 0.14),
|
|
||||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
padding: 0.25rem;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
> li {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
text-align: start;
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
button:hover,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
button:focus-visible {
|
|
||||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
button[disabled] {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
li:has(> .app-sidebar-bookmark-action-danger) {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
padding-top: 0.2rem;
|
|
||||||
border-top: 1px solid var(--app-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
.app-sidebar-bookmark-action-danger {
|
|
||||||
color: var(--app-del-color, #c62828);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
.app-sidebar-bookmark-action-danger:hover,
|
|
||||||
.app-sidebar
|
|
||||||
#aside-panel-bookmarks
|
|
||||||
.app-sidebar-bookmark-action-menu-list
|
|
||||||
.app-sidebar-bookmark-action-danger:focus-visible {
|
|
||||||
background: rgba(198, 40, 40, 0.12);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use MintyPHP\Http\RequestContext;
|
|||||||
use MintyPHP\Http\Request;
|
use MintyPHP\Http\Request;
|
||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||||
use MintyPHP\Service\Access\UiAccessService;
|
use MintyPHP\Service\Access\UiAccessService;
|
||||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
@@ -24,12 +26,27 @@ chdir(__DIR__ . '/..');
|
|||||||
require 'vendor/autoload.php';
|
require 'vendor/autoload.php';
|
||||||
// Load the config parameters
|
// Load the config parameters
|
||||||
require 'config/config.php';
|
require 'config/config.php';
|
||||||
// Load the routes
|
|
||||||
require 'config/router.php';
|
|
||||||
// Register shortcut functions
|
// Register shortcut functions
|
||||||
require 'lib/Support/helpers.php';
|
require 'lib/Support/helpers.php';
|
||||||
$container = require 'lib/App/registerContainer.php';
|
$container = require 'lib/App/registerContainer.php';
|
||||||
setAppContainer($container);
|
setAppContainer($container);
|
||||||
|
|
||||||
|
// Resolve runtime page root before routes are initialized.
|
||||||
|
$moduleRegistry = app(ModuleRegistry::class);
|
||||||
|
if (count($moduleRegistry->getModules()) > 0) {
|
||||||
|
$runtimePageRoot = __DIR__ . '/../storage/runtime/pages';
|
||||||
|
if (!is_dir($runtimePageRoot) && !is_link($runtimePageRoot)) {
|
||||||
|
$builder = new ModuleRuntimePageBuilder();
|
||||||
|
$builder->build($runtimePageRoot, __DIR__ . '/../pages', $moduleRegistry->getModules());
|
||||||
|
}
|
||||||
|
if (is_dir($runtimePageRoot) || is_link($runtimePageRoot)) {
|
||||||
|
Router::$pageRoot = 'storage/runtime/pages/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load routes after container/module registry are available.
|
||||||
|
require 'config/router.php';
|
||||||
|
|
||||||
RequestContext::start();
|
RequestContext::start();
|
||||||
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
|
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
|
||||||
|
|
||||||
@@ -200,8 +217,8 @@ if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) {
|
|||||||
// Guard: restrict non-public pages to logged-in users
|
// Guard: restrict non-public pages to logged-in users
|
||||||
// Merge core public paths with module-contributed public paths.
|
// Merge core public paths with module-contributed public paths.
|
||||||
$modulePublicPaths = [];
|
$modulePublicPaths = [];
|
||||||
if (app(\MintyPHP\App\Module\ModuleRegistry::class) instanceof \MintyPHP\App\Module\ModuleRegistry) {
|
if (app(ModuleRegistry::class) instanceof ModuleRegistry) {
|
||||||
$modulePublicPaths = app(\MintyPHP\App\Module\ModuleRegistry::class)->getPublicPaths();
|
$modulePublicPaths = app(ModuleRegistry::class)->getPublicPaths();
|
||||||
}
|
}
|
||||||
$allPublicPaths = array_values(array_unique(array_merge(
|
$allPublicPaths = array_values(array_unique(array_merge(
|
||||||
defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [],
|
defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [],
|
||||||
|
|||||||
@@ -12,19 +12,36 @@
|
|||||||
root.classList.add('js');
|
root.classList.add('js');
|
||||||
root.classList.add('aside-pending');
|
root.classList.add('aside-pending');
|
||||||
|
|
||||||
var assetBase = root.dataset.assetBase || '';
|
var normalizeToken = function (value) {
|
||||||
if (assetBase) {
|
return String(value == null ? '' : value)
|
||||||
window.APP_ASSET_BASE = assetBase;
|
.trim()
|
||||||
}
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
};
|
||||||
|
var buildStorageKey = function (scope, key) {
|
||||||
|
var namespace = normalizeToken('app-ui') || 'app-ui';
|
||||||
|
var version = normalizeToken('v1') || 'v1';
|
||||||
|
var normalizedScope = normalizeToken(scope || '') || 'global';
|
||||||
|
var normalizedKey = normalizeToken(key || '');
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return namespace + ':' + version + ':' + normalizedScope + ':state:' + normalizedKey;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.localStorage.getItem('app-sidebar-collapsed') === '1') {
|
var sidebarCollapsedKey = buildStorageKey('sidebar', 'sidebar-collapsed');
|
||||||
|
var sidebarHiddenKey = buildStorageKey('sidebar', 'sidebar-hidden');
|
||||||
|
var contrastKey = buildStorageKey('contrast', 'contrast-mode');
|
||||||
|
|
||||||
|
if (sidebarCollapsedKey && window.localStorage.getItem(sidebarCollapsedKey) === '1') {
|
||||||
root.classList.add('sidebar-collapsed');
|
root.classList.add('sidebar-collapsed');
|
||||||
}
|
}
|
||||||
if (window.localStorage.getItem('app-sidebar-hidden') === '1') {
|
if (sidebarHiddenKey && window.localStorage.getItem(sidebarHiddenKey) === '1') {
|
||||||
root.classList.add('sidebar-hidden');
|
root.classList.add('sidebar-hidden');
|
||||||
}
|
}
|
||||||
var contrastValue = window.localStorage.getItem('app-contrast');
|
var contrastValue = contrastKey ? window.localStorage.getItem(contrastKey) : null;
|
||||||
if (contrastValue === 'high') {
|
if (contrastValue === 'high') {
|
||||||
root.dataset.contrast = 'high';
|
root.dataset.contrast = 'high';
|
||||||
} else {
|
} else {
|
||||||
@@ -33,16 +50,4 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.requestFsLightboxRefresh !== 'function') {
|
|
||||||
window.requestFsLightboxRefresh = function () {
|
|
||||||
if (typeof window.refreshFsLightbox === 'function') {
|
|
||||||
window.refreshFsLightbox();
|
|
||||||
window.__fsLightboxRefreshPending = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
window.__fsLightboxRefreshPending = true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user