diff --git a/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php b/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php deleted file mode 100644 index 5aba304..0000000 --- a/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php +++ /dev/null @@ -1,40 +0,0 @@ -queryAll(); - } catch (\Throwable) { - // fail-open: may not be available in CLI context - } - - return [ - 'addressBook' => [ - 'url' => lurl('address-book'), - 'activeSearch' => trim((string) ($query['search'] ?? '')), - 'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''), - 'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''), - 'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''), - 'activeCustomFilters' => appNormalizeAddressBookCustomFilterQuery($query), - 'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null) - ? $session['available_departments_by_tenant'] - : [], - ], - ]; - } -} diff --git a/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php b/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php deleted file mode 100644 index 57e8684..0000000 --- a/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php +++ /dev/null @@ -1,30 +0,0 @@ -permissionService->userHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } +} diff --git a/modules/addressbook/lib/Module/AddressBook/AddressBookContainerRegistrar.php b/modules/addressbook/lib/Module/AddressBook/AddressBookContainerRegistrar.php new file mode 100644 index 0000000..996af28 --- /dev/null +++ b/modules/addressbook/lib/Module/AddressBook/AddressBookContainerRegistrar.php @@ -0,0 +1,29 @@ +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(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c + ->get(AddressBookServicesFactory::class) + ->createAddressBookService()); + } +} diff --git a/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php b/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php new file mode 100644 index 0000000..07b0aed --- /dev/null +++ b/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookLayoutProvider.php @@ -0,0 +1,80 @@ +queryAll(); + } catch (\Throwable) { + // fail-open: may not be available in CLI context + } + + return [ + 'addressbook.nav' => [ + 'url' => lurl('address-book'), + 'activeSearch' => trim((string) ($query['search'] ?? '')), + 'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''), + 'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''), + 'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''), + 'activeCustomFilters' => self::normalizeCustomFilterQuery($query), + 'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null) + ? $session['available_departments_by_tenant'] + : [], + ], + ]; + } + + /** + * Normalize address-book custom field query keys/values. + * + * @param array $rawQuery + * @return array> + */ + public static function normalizeCustomFilterQuery(array $rawQuery): array + { + $normalized = []; + foreach ($rawQuery as $rawKey => $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $normalized[$key] = $value; + } + continue; + } + if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { + $ids = appNormalizePositiveIntList($rawValue); + if ($ids) { + $normalized[$key] = $ids; + } + continue; + } + if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { + $value = trim((string) $rawValue); + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + if ($dt && $dt->format('Y-m-d') === $value) { + $normalized[$key] = $value; + } + } + } + ksort($normalized, SORT_STRING); + return $normalized; + } +} diff --git a/lib/Module/AddressBook/Providers/AddressBookSearchProvider.php b/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookSearchProvider.php similarity index 100% rename from lib/Module/AddressBook/Providers/AddressBookSearchProvider.php rename to modules/addressbook/lib/Module/AddressBook/Providers/AddressBookSearchProvider.php diff --git a/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php b/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php new file mode 100644 index 0000000..a158c54 --- /dev/null +++ b/modules/addressbook/lib/Module/AddressBook/Providers/AddressBookSessionProvider.php @@ -0,0 +1,40 @@ +get(UserTenantContextService::class); + $sessionStore = $container->get(SessionStoreInterface::class); + if (!$tenantContext instanceof UserTenantContextService || !$sessionStore instanceof SessionStoreInterface) { + return; + } + + $sessionStore->set( + 'available_departments_by_tenant', + $tenantContext->getAvailableDepartmentsByTenant($userId) + ); + } + + public function clear(): void + { + unset($_SESSION['available_departments_by_tenant']); + } +} diff --git a/lib/Service/AddressBook/AddressBookService.php b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php similarity index 97% rename from lib/Service/AddressBook/AddressBookService.php rename to modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php index e7686c7..40bd6d7 100644 --- a/lib/Service/AddressBook/AddressBookService.php +++ b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php @@ -1,6 +1,6 @@ roleService->listActive(); - $customFieldFilterSpec = $this->customFieldValueService->extractAddressBookFilterSpec($query, $currentUserId); + $customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId); $customFieldFilterDefinitions = $this->normalizeDefinitions( - $customFieldFilterSpec['definitions'] ?? [] + $customFieldFilterSpec['definitions'] ); - $customFieldFilterQuery = is_array($customFieldFilterSpec['query'] ?? null) - ? $customFieldFilterSpec['query'] - : []; + $customFieldFilterQuery = $customFieldFilterSpec['query']; $customFieldDefinitionIds = []; foreach ($customFieldFilterDefinitions as $definition) { @@ -161,7 +159,7 @@ class AddressBookService $tenants = $query['tenants'] ?? ''; $departments = $query['departments'] ?? ''; $roles = $query['roles'] ?? ''; - $customFieldFilterSpec = $this->customFieldValueService->extractAddressBookFilterSpec($query, $currentUserId); + $customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId); $result = $this->userAccountService->listPaged([ 'limit' => $limit, diff --git a/lib/Service/AddressBook/AddressBookServicesFactory.php b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookServicesFactory.php similarity index 96% rename from lib/Service/AddressBook/AddressBookServicesFactory.php rename to modules/addressbook/lib/Module/AddressBook/Service/AddressBookServicesFactory.php index b76b270..c2ceb7b 100644 --- a/lib/Service/AddressBook/AddressBookServicesFactory.php +++ b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookServicesFactory.php @@ -1,6 +1,6 @@ 'addressbook', @@ -17,9 +12,18 @@ return [ 'enabled_by_default' => false, 'load_order' => 10, - 'routes' => [], + 'routes' => [ + // Keep canonical route stable without forcing a /index target to avoid Router redirect loops. + ['path' => 'address-book', 'target' => 'address-book'], + ['path' => 'address-book/data', 'target' => 'address-book/data'], + // Legacy aliases for existing bookmarks/links. + ['path' => 'addressbook', 'target' => 'address-book'], + ['path' => 'adressbook', 'target' => 'address-book'], + ], 'public_paths' => [], - 'container_registrars' => [], + 'container_registrars' => [ + \MintyPHP\Module\AddressBook\AddressBookContainerRegistrar::class, + ], 'ui_slots' => [ 'aside.tab_panel' => [ @@ -32,15 +36,47 @@ return [ 'panel_template' => __DIR__ . '/templates/aside-people-panel.phtml', 'details_storage' => 'aside-people-tenant', 'details_open_active' => true, + 'order' => 110, ], ], + 'search.resource_item' => [ + [ + 'key' => 'address-book', + 'label' => 'Address book', + 'base_url' => 'address-book', + 'permission' => 'can_view_address_book', + 'order' => 110, + ], + ], + 'user.edit.aside_action' => [ + [ + 'key' => 'address-book-view-user', + 'type' => 'link', + 'label' => 'View in address book', + 'href' => 'address-book/view/{user_uuid}', + 'permission' => 'can_view_address_book', + 'order' => 110, + ], + ], + ], + + 'authorization_policies' => [ + \MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::class, + ], + + 'layout_capabilities' => [ + 'can_view_address_book' => 'addressbook.view', ], 'search_resources' => [ \MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider::class, ], - 'asset_groups' => [], + 'asset_groups' => [ + 'address-book' => [ + 'modules/addressbook/css/pages/address-book-view.css', + ], + ], 'scheduler_jobs' => [], 'layout_context_providers' => [ @@ -51,7 +87,14 @@ return [ \MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class, ], - 'permissions' => [], + 'permissions' => [ + [ + 'key' => 'address_book.view', + 'description' => 'Can view address book', + 'active' => true, + 'is_system' => true, + ], + ], 'migrations_path' => 'db/updates', ]; diff --git a/pages/address-book/data().php b/modules/addressbook/pages/address-book/data().php similarity index 79% rename from pages/address-book/data().php rename to modules/addressbook/pages/address-book/data().php index 8b30c9a..347dbc9 100644 --- a/pages/address-book/data().php +++ b/modules/addressbook/pages/address-book/data().php @@ -1,13 +1,13 @@ all(); Guard::requireLogin(); -Guard::requireAbilityOrForbidden(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); +Guard::requireAbilityOrForbidden(AddressBookAuthorizationPolicy::ABILITY_VIEW); gridRequireGetRequest(); $query = requestInput()->queryAll(); diff --git a/pages/address-book/filter-schema.php b/modules/addressbook/pages/address-book/filter-schema.php similarity index 100% rename from pages/address-book/filter-schema.php rename to modules/addressbook/pages/address-book/filter-schema.php diff --git a/pages/address-book/index($slug).php b/modules/addressbook/pages/address-book/index($slug).php similarity index 100% rename from pages/address-book/index($slug).php rename to modules/addressbook/pages/address-book/index($slug).php diff --git a/pages/address-book/index().php b/modules/addressbook/pages/address-book/index().php similarity index 96% rename from pages/address-book/index().php rename to modules/addressbook/pages/address-book/index().php index 66cce44..f0c2695 100644 --- a/pages/address-book/index().php +++ b/modules/addressbook/pages/address-book/index().php @@ -2,13 +2,13 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; -use MintyPHP\Service\AddressBook\AddressBookServicesFactory; +use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory; use MintyPHP\Session; use MintyPHP\Support\Guard; $session = app(SessionStoreInterface::class)->all(); Guard::requireLogin(); -Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); +Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW); $filterSchema = require __DIR__ . '/filter-schema.php'; $query = requestInput()->queryAll(); diff --git a/pages/address-book/index(default).phtml b/modules/addressbook/pages/address-book/index(default).phtml similarity index 98% rename from pages/address-book/index(default).phtml rename to modules/addressbook/pages/address-book/index(default).phtml index f1e58ac..89ccbd9 100644 --- a/pages/address-book/index(default).phtml +++ b/modules/addressbook/pages/address-book/index(default).phtml @@ -239,4 +239,4 @@ $pageConfig = [ ?> - + diff --git a/pages/address-book/view($id).php b/modules/addressbook/pages/address-book/view($id).php similarity index 84% rename from pages/address-book/view($id).php rename to modules/addressbook/pages/address-book/view($id).php index 37e4c84..6a487b8 100644 --- a/pages/address-book/view($id).php +++ b/modules/addressbook/pages/address-book/view($id).php @@ -8,7 +8,7 @@ use MintyPHP\Support\Guard; $session = app(SessionStoreInterface::class)->all(); Guard::requireLogin(); -Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); +Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW); $currentUserId = (int) ($session['user']['id'] ?? 0); $uuid = trim((string) ($id ?? '')); @@ -16,7 +16,7 @@ if ($uuid === '') { Router::redirect('address-book'); } -$addressBookService = app(\MintyPHP\Service\AddressBook\AddressBookService::class); +$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class); $viewContext = $addressBookService->buildViewContext($currentUserId, $uuid); $status = (string) ($viewContext['status'] ?? ''); if ($status === 'not_found') { diff --git a/pages/address-book/view(default).phtml b/modules/addressbook/pages/address-book/view(default).phtml similarity index 98% rename from pages/address-book/view(default).phtml rename to modules/addressbook/pages/address-book/view(default).phtml index d6d2810..3a2c1ba 100644 --- a/pages/address-book/view(default).phtml +++ b/modules/addressbook/pages/address-book/view(default).phtml @@ -91,7 +91,7 @@ if ($profileDescription !== '') {
-
+
diff --git a/modules/addressbook/templates/aside-people-panel.phtml b/modules/addressbook/templates/aside-people-panel.phtml index 2cb2d5c..9cee5bd 100644 --- a/modules/addressbook/templates/aside-people-panel.phtml +++ b/modules/addressbook/templates/aside-people-panel.phtml @@ -8,7 +8,7 @@ * * Available in scope via include: $layoutNav, $layoutAuth, $viewAuth */ -$addressBook = is_array($layoutNav['addressBook'] ?? null) ? $layoutNav['addressBook'] : []; +$addressBook = is_array($layoutNav['addressbook.nav'] ?? null) ? $layoutNav['addressbook.nav'] : []; $addressBookUrl = trim((string) ($addressBook['url'] ?? lurl('address-book'))); $activeAddressSearch = trim((string) ($addressBook['activeSearch'] ?? '')); $activeAddressTenants = is_array($addressBook['activeTenants'] ?? null) ? $addressBook['activeTenants'] : []; diff --git a/tests/Service/AddressBook/AddressBookServiceTest.php b/modules/addressbook/tests/Module/AddressBook/Service/AddressBookServiceTest.php similarity index 94% rename from tests/Service/AddressBook/AddressBookServiceTest.php rename to modules/addressbook/tests/Module/AddressBook/Service/AddressBookServiceTest.php index 145f45b..0e1e7fd 100644 --- a/tests/Service/AddressBook/AddressBookServiceTest.php +++ b/modules/addressbook/tests/Module/AddressBook/Service/AddressBookServiceTest.php @@ -1,9 +1,9 @@ expects($this->once()) - ->method('extractAddressBookFilterSpec') + ->method('extractCustomFieldFilterSpec') ->with([], 7) ->willReturn([ 'definitions' => [], diff --git a/web/css/pages/address-book-view.css b/modules/addressbook/web/css/pages/address-book-view.css similarity index 100% rename from web/css/pages/address-book-view.css rename to modules/addressbook/web/css/pages/address-book-view.css diff --git a/web/js/pages/address-book-index.js b/modules/addressbook/web/js/pages/address-book-index.js similarity index 92% rename from web/js/pages/address-book-index.js rename to modules/addressbook/web/js/pages/address-book-index.js index 5fc4c6d..df339b9 100644 --- a/web/js/pages/address-book-index.js +++ b/modules/addressbook/web/js/pages/address-book-index.js @@ -1,9 +1,9 @@ -import { initMultiSelect } from '../components/app-multiselect-init.js'; -import { initMultiSelectCascade } from '../components/app-multiselect-cascade.js'; -import { initStandardListPage } from '../core/app-grid-factory.js'; -import { readPageConfig } from '../core/app-page-config.js'; -import { warnOnce } from '../core/app-dom.js'; -import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from './app-list-utils.js'; +import { initMultiSelect } from '/js/components/app-multiselect-init.js'; +import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js'; +import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { readPageConfig } from '/js/core/app-page-config.js'; +import { warnOnce } from '/js/core/app-dom.js'; +import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from '/js/pages/app-list-utils.js'; const config = readPageConfig('address-book-index'); if (config) { diff --git a/tests/Architecture/NoAddressBookHardcodingTest.php b/tests/Architecture/NoAddressBookHardcodingTest.php index 7c4da37..4f5cc9e 100644 --- a/tests/Architecture/NoAddressBookHardcodingTest.php +++ b/tests/Architecture/NoAddressBookHardcodingTest.php @@ -14,9 +14,7 @@ use FilesystemIterator; * Intentionally allowed in Core (not flagged): * - PermissionService::ADDRESS_BOOK_VIEW (general people-visibility gate) * - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization) - * - AddressBookService / AddressBookServicesFactory (business logic, V1 shared-db) - * - pages/address-book/* (page files, not hardcoded integrations) - * - config/assets.php (asset pipeline not yet modular) + * - modules/addressbook/* (module-owned runtime, pages/assets/providers) * - i18n translation files * - Module directory (lib/Module/, modules/) * - Test files @@ -90,10 +88,7 @@ final class NoAddressBookHardcodingTest extends TestCase } /** - * Aside panel template must not have hardcoded People panel. - * - * Note: The search panel legitimately contains data-search-key="address-book" - * for the global search results — that is NOT a hardcoded People panel. + * Aside panel template must not have hardcoded People panel/search entries. */ public function testAsideHasNoHardcodedPeoplePanel(): void { @@ -116,6 +111,20 @@ final class NoAddressBookHardcodingTest extends TestCase $content, 'Aside still defines $peopleGroups as a standalone variable' ); + self::assertStringNotContainsString( + 'data-search-key="address-book"', + $content, + 'Aside still has static address-book search item markup' + ); + } + + public function testCoreNoLongerContainsAddressBookServiceDirectory(): void + { + $coreServiceDir = dirname(__DIR__, 2) . '/lib/Service/AddressBook'; + self::assertDirectoryDoesNotExist( + $coreServiceDir, + 'Addressbook service classes should live in modules/addressbook/lib, not in Core lib/Service' + ); } /** diff --git a/tests/Unit/Module/AddressBookAuthorizationPolicyTest.php b/tests/Unit/Module/AddressBookAuthorizationPolicyTest.php new file mode 100644 index 0000000..280b835 --- /dev/null +++ b/tests/Unit/Module/AddressBookAuthorizationPolicyTest.php @@ -0,0 +1,87 @@ +createMock(PermissionService::class)); + self::assertInstanceOf(AuthorizationPolicyInterface::class, $policy); + } + + public function testSupportsOwnAbility(): void + { + $policy = new AddressBookAuthorizationPolicy($this->createMock(PermissionService::class)); + self::assertTrue($policy->supports(AddressBookAuthorizationPolicy::ABILITY_VIEW)); + } + + public function testDoesNotSupportOtherAbilities(): void + { + $policy = new AddressBookAuthorizationPolicy($this->createMock(PermissionService::class)); + self::assertFalse($policy->supports('ops.admin.jobs.view')); + self::assertFalse($policy->supports('')); + self::assertFalse($policy->supports('ops.address_book.view')); // old ability string + } + + public function testAllowedWithPermission(): void + { + $permissionService = $this->permissionGatewayAllowing([ + 5 => [PermissionService::ADDRESS_BOOK_VIEW], + ]); + + $policy = new AddressBookAuthorizationPolicy($permissionService); + $decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [ + 'actor_user_id' => 5, + ]); + + self::assertTrue($decision->isAllowed()); + } + + public function testDeniedWithoutPermission(): void + { + $permissionService = $this->permissionGatewayAllowing([5 => []]); + + $policy = new AddressBookAuthorizationPolicy($permissionService); + $decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [ + 'actor_user_id' => 5, + ]); + + $this->assertForbiddenDecision($decision); + } + + public function testDeniedWithZeroActorId(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->never())->method('userHas'); + + $policy = new AddressBookAuthorizationPolicy($permissionService); + $decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [ + 'actor_user_id' => 0, + ]); + + $this->assertForbiddenDecision($decision); + } + + public function testDeniedWithMissingActorId(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->never())->method('userHas'); + + $policy = new AddressBookAuthorizationPolicy($permissionService); + $decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, []); + + $this->assertForbiddenDecision($decision); + } +} diff --git a/web/modules/addressbook b/web/modules/addressbook new file mode 120000 index 0000000..488ee0b --- /dev/null +++ b/web/modules/addressbook @@ -0,0 +1 @@ +/var/www/modules/addressbook/web \ No newline at end of file