From 346b897e257186d1950ffb6f85671dd353336092 Mon Sep 17 00:00:00 2001 From: "Quintino A. G. Souza" Date: Sat, 28 Feb 2026 09:55:54 -0300 Subject: [PATCH] =?UTF-8?q?Inicializa=20m=C3=B3dulo=20base=20ldap=5Fgroups?= =?UTF-8?q?=5Fsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cria super-módulo com infraestrutura compartilhada de regras de acesso para os módulos de sincronização LDAP de grupos. - GroupAccessRulesService: serviço parametrizável por config name - AccessRulesFormBase: listagem/remoção de regras (classe abstrata) - AccessRuleFormBase: formulário modal de criação/edição (classe abstrata) - Sub-módulos ldap_departments_sync e ldap_research_groups_sync refatorados para estender as classes base com subclasses mínimas - Traduções pt-br centralizadas em ldap_groups_sync.pt-br.po Co-Authored-By: Claude Sonnet 4.6 --- README.md | 99 ++ ldap_departments_sync/CHANGELOG.md | 46 + ldap_departments_sync/README.md | 184 ++ ...form_display.group.departments.default.yml | 127 ++ ...view_display.group.departments.default.yml | 107 ++ ....entity_view_display.user.user.default.yml | 68 + ...d.group.departments.field_dept_acronym.yml | 18 + ...ield.group.departments.field_dept_code.yml | 18 + ...eld.group.departments.field_dept_coord.yml | 28 + ...oup.departments.field_dept_coord_assoc.yml | 28 + ...ield.group.departments.field_dept_mail.yml | 18 + ...eld.group.departments.field_dept_phone.yml | 18 + ...ield.group.departments.field_dept_room.yml | 18 + ...ield.group.departments.field_dept_type.yml | 20 + ...d.group.departments.field_parent_group.yml | 27 + ...ld.field.user.user.field_user_category.yml | 19 + ....field.user.user.field_user_department.yml | 24 + ...d.field.user.user.field_user_dept_code.yml | 19 + ....field.user.user.field_user_work_phone.yml | 20 + ...field.storage.group.field_dept_acronym.yml | 20 + .../field.storage.group.field_dept_code.yml | 20 + .../field.storage.group.field_dept_coord.yml | 19 + ...d.storage.group.field_dept_coord_assoc.yml | 19 + .../field.storage.group.field_dept_mail.yml | 17 + .../field.storage.group.field_dept_phone.yml | 20 + .../field.storage.group.field_dept_room.yml | 20 + .../field.storage.group.field_dept_type.yml | 26 + ...field.storage.group.field_parent_group.yml | 18 + ...field.storage.user.field_user_category.yml | 20 + ...eld.storage.user.field_user_department.yml | 19 + ...ield.storage.user.field_user_dept_code.yml | 20 + ...eld.storage.user.field_user_work_phone.yml | 18 + .../optional/group.role.departments-admin.yml | 45 + .../group.role.departments-admin_in.yml | 46 + .../group.role.departments-admin_out.yml | 46 + .../group.role.departments-anonymous.yml | 17 + .../group.role.departments-member.yml | 36 + .../group.role.departments-outsider.yml | 17 + .../optional/group.type.departments.yml | 10 + ....field.user.user.field_user_work_phone.yml | 2 + ldap_departments_sync/css/role-mapping.css | 73 + .../ldap_departments_sync.info.yml | 13 + .../ldap_departments_sync.install | 106 ++ .../ldap_departments_sync.libraries.yml | 5 + .../ldap_departments_sync.links.menu.yml | 6 + .../ldap_departments_sync.links.task.yml | 11 + .../ldap_departments_sync.module | 291 ++++ .../ldap_departments_sync.permissions.yml | 9 + .../ldap_departments_sync.routing.yml | 25 + .../ldap_departments_sync.services.yml | 8 + .../src/Controller/LocalModulesController.php | 22 + .../src/Form/AccessRuleForm.php | 40 + .../src/Form/AccessRulesForm.php | 33 + .../Form/LdapDepartmentsSyncConfigForm.php | 1517 ++++++++++++++++ .../src/LdapDepartmentsSync.php | 1531 +++++++++++++++++ .../DepartmentSelection.php | 105 ++ .../ldap_departments_sync.pt-br.po | 286 +++ ldap_departments_sync/translations/pt-br.po | 152 ++ ldap_groups_sync.info.yml | 11 + ldap_research_groups_sync/README.md | 69 + ...m_display.group.research_group.default.yml | 102 ++ ...w_display.group.research_group.default.yml | 81 + ...eld.group.research_group.field_rg_code.yml | 18 + ...ld.group.research_group.field_rg_coord.yml | 30 + ...up.research_group.field_rg_coord_assoc.yml | 30 + ...oup.research_group.field_rg_department.yml | 28 + ...eld.group.research_group.field_rg_mail.yml | 18 + ...ld.group.research_group.field_rg_phone.yml | 20 + ...d.user.user.field_user_research_groups.yml | 28 + .../field.storage.group.field_rg_code.yml | 20 + .../field.storage.group.field_rg_coord.yml | 19 + ...eld.storage.group.field_rg_coord_assoc.yml | 19 + ...ield.storage.group.field_rg_department.yml | 18 + .../field.storage.group.field_rg_mail.yml | 17 + .../field.storage.group.field_rg_phone.yml | 18 + ...torage.user.field_user_research_groups.yml | 19 + .../group.role.research_group-admin.yml | 37 + .../group.role.research_group-admin_in.yml | 38 + .../group.role.research_group-admin_out.yml | 38 + .../group.role.research_group-anonymous.yml | 17 + .../group.role.research_group-member.yml | 27 + .../group.role.research_group-outsider.yml | 17 + .../optional/group.type.research_group.yml | 10 + .../css/role-mapping.css | 73 + .../ldap_research_groups_sync.info.yml | 13 + .../ldap_research_groups_sync.install | 94 + .../ldap_research_groups_sync.libraries.yml | 5 + .../ldap_research_groups_sync.links.menu.yml | 6 + .../ldap_research_groups_sync.links.task.yml | 11 + .../ldap_research_groups_sync.module | 238 +++ .../ldap_research_groups_sync.permissions.yml | 9 + .../ldap_research_groups_sync.routing.yml | 25 + .../ldap_research_groups_sync.services.yml | 8 + .../scripts/install_field_rg_department.php | 85 + .../src/Form/AccessRuleForm.php | 40 + .../src/Form/AccessRulesForm.php | 33 + .../Form/LdapResearchGroupsSyncConfigForm.php | 1310 ++++++++++++++ .../src/LdapResearchGroupsSync.php | 1212 +++++++++++++ .../ResearchGroupSelection.php | 31 + .../ldap_research_groups_sync.pt-br.po | 298 ++++ src/Form/AccessRuleFormBase.php | 771 +++++++++ src/Form/AccessRulesFormBase.php | 221 +++ src/GroupAccessRulesService.php | 276 +++ translations/ldap_groups_sync.pt-br.po | 193 +++ 104 files changed, 11315 insertions(+) create mode 100644 README.md create mode 100644 ldap_departments_sync/CHANGELOG.md create mode 100644 ldap_departments_sync/README.md create mode 100644 ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml create mode 100644 ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml create mode 100644 ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml create mode 100644 ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml create mode 100644 ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml create mode 100644 ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml create mode 100644 ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml create mode 100644 ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml create mode 100644 ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-admin.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-admin_in.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-admin_out.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-anonymous.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-member.yml create mode 100644 ldap_departments_sync/config/optional/group.role.departments-outsider.yml create mode 100644 ldap_departments_sync/config/optional/group.type.departments.yml create mode 100644 ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml create mode 100644 ldap_departments_sync/css/role-mapping.css create mode 100644 ldap_departments_sync/ldap_departments_sync.info.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.install create mode 100644 ldap_departments_sync/ldap_departments_sync.libraries.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.links.menu.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.links.task.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.module create mode 100644 ldap_departments_sync/ldap_departments_sync.permissions.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.routing.yml create mode 100644 ldap_departments_sync/ldap_departments_sync.services.yml create mode 100644 ldap_departments_sync/src/Controller/LocalModulesController.php create mode 100644 ldap_departments_sync/src/Form/AccessRuleForm.php create mode 100644 ldap_departments_sync/src/Form/AccessRulesForm.php create mode 100644 ldap_departments_sync/src/Form/LdapDepartmentsSyncConfigForm.php create mode 100644 ldap_departments_sync/src/LdapDepartmentsSync.php create mode 100644 ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php create mode 100644 ldap_departments_sync/translations/ldap_departments_sync.pt-br.po create mode 100644 ldap_departments_sync/translations/pt-br.po create mode 100644 ldap_groups_sync.info.yml create mode 100644 ldap_research_groups_sync/README.md create mode 100644 ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml create mode 100644 ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml create mode 100644 ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml create mode 100644 ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-member.yml create mode 100644 ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml create mode 100644 ldap_research_groups_sync/config/optional/group.type.research_group.yml create mode 100644 ldap_research_groups_sync/css/role-mapping.css create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.info.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.install create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.module create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.routing.yml create mode 100644 ldap_research_groups_sync/ldap_research_groups_sync.services.yml create mode 100644 ldap_research_groups_sync/scripts/install_field_rg_department.php create mode 100644 ldap_research_groups_sync/src/Form/AccessRuleForm.php create mode 100644 ldap_research_groups_sync/src/Form/AccessRulesForm.php create mode 100644 ldap_research_groups_sync/src/Form/LdapResearchGroupsSyncConfigForm.php create mode 100644 ldap_research_groups_sync/src/LdapResearchGroupsSync.php create mode 100644 ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php create mode 100644 ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po create mode 100644 src/Form/AccessRuleFormBase.php create mode 100644 src/Form/AccessRulesFormBase.php create mode 100644 src/GroupAccessRulesService.php create mode 100644 translations/ldap_groups_sync.pt-br.po diff --git a/README.md b/README.md new file mode 100644 index 0000000..0045428 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# LDAP Groups Sync + +Módulo base que provê a infraestrutura compartilhada de **regras de acesso** para os módulos de sincronização LDAP de grupos. + +Não realiza sincronização por si só — funciona como biblioteca de classes abstratas instanciada pelos sub-módulos que ficam dentro deste repositório. + +## Sub-módulos incluídos + +| Módulo | Tipo de grupo | Rota de configuração | +|---|---|---| +| `ldap_departments_sync` | `departments` | `/admin/config/local-modules/ldap-departments-sync` | +| `ldap_research_groups_sync` | `research_group` | `/admin/config/local-modules/ldap-research-groups-sync` | + +## Estrutura + +``` +ldap_groups_sync/ +├── src/ +│ ├── GroupAccessRulesService.php # serviço de verificação de acesso +│ └── Form/ +│ ├── AccessRulesFormBase.php # listagem e remoção de regras +│ └── AccessRuleFormBase.php # criação/edição de regra (modal) +├── translations/ +│ └── ldap_groups_sync.pt-br.po +│ +├── ldap_departments_sync/ # sub-módulo +└── ldap_research_groups_sync/ # sub-módulo +``` + +## Regras de acesso + +A infraestrutura de regras de acesso permite configurar, por interface, quais operações (`create`, `update`, `delete`, `view`) cada entidade permite — e a quais membros de grupo com quais papéis. + +Cada regra define: + +- **Tipo de entidade / bundle** ao qual se aplica +- **Operações** controladas +- **Modo**: _Restritivo_ (nega quem não satisfaz) ou _Aditivo_ (só concede, sem negar) +- **Requisitos de membro**: um ou mais pares (grupo + papéis requeridos) +- **Condições de campo** opcionais avaliadas sobre a entidade existente (para `update`/`delete`/`view`) + +## Como adicionar um novo sub-módulo + +1. Crie o diretório do módulo dentro de `ldap_groups_sync/`. + +2. Declare `ldap_groups_sync:ldap_groups_sync` como dependência no `.info.yml`. + +3. No `.services.yml`, use `GroupAccessRulesService` com o config name como terceiro argumento: + +```yaml +meu_modulo.access_rules: + class: Drupal\ldap_groups_sync\GroupAccessRulesService + arguments: ['@config.factory', '@entity_type.manager', 'meu_modulo.settings'] +``` + +4. Crie `src/Form/AccessRulesForm.php` estendendo `AccessRulesFormBase`: + +```php +namespace Drupal\meu_modulo\Form; + +use Drupal\ldap_groups_sync\Form\AccessRulesFormBase; + +class AccessRulesForm extends AccessRulesFormBase { + protected function getConfigName(): string { return 'meu_modulo.settings'; } + public function getFormId(): string { return 'meu_modulo_access_rules_form'; } + protected function getAccessRuleFormRoute(): string { return 'meu_modulo.access_rule_form'; } +} +``` + +5. Crie `src/Form/AccessRuleForm.php` estendendo `AccessRuleFormBase`: + +```php +namespace Drupal\meu_modulo\Form; + +use Drupal\ldap_groups_sync\Form\AccessRuleFormBase; + +class AccessRuleForm extends AccessRuleFormBase { + protected function getConfigName(): string { return 'meu_modulo.settings'; } + public function getFormId(): string { return 'meu_modulo_access_rule_form'; } + protected function getAccessRulesRoute(): string { return 'meu_modulo.access_rules'; } + protected function getDefaultGroupTypeId(): string { return 'meu_group_type'; } +} +``` + +## Dependências + +- `drupal:options` +- `drupal:telephone` +- `group:group` +- `ldap:ldap_servers` +- `site_tools` + +## Licença + +GPL-2.0-or-later + +## Autor + +Desenvolvido para o IME - UNICAMP diff --git a/ldap_departments_sync/CHANGELOG.md b/ldap_departments_sync/CHANGELOG.md new file mode 100644 index 0000000..d6a4dd9 --- /dev/null +++ b/ldap_departments_sync/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to the LDAP Departments Sync module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-11-07 + +### Added +- Initial stable release of LDAP Departments Sync module +- LDAP server and query configuration with dynamic dropdowns +- Hierarchical group organization based on LDAP attributes +- Dynamic attribute mapping system for flexible field configuration +- User reference mapping with DN to Drupal User resolution +- Automatic search attribute detection from LDAP server configuration +- Comprehensive cron validation to prevent execution with incomplete configurations +- Administrative interface with management buttons for LDAP queries +- Extensive logging and error handling throughout the synchronization process +- Support for both manual and automatic synchronization via cron + +### Features +- **LDAP Integration**: Full integration with `ldap_servers` and `ldap_query` modules +- **Dynamic Configuration**: AJAX-powered form with real-time updates based on selections +- **Hierarchical Groups**: Automatic parent-child relationship mapping from LDAP data +- **User Reference Mapping**: Convert LDAP DNs to Drupal User references automatically +- **Flexible Mapping**: Support for both simple text and user reference field mappings +- **Validation**: Comprehensive configuration validation before cron execution +- **Management Tools**: Direct links to edit LDAP queries +- **Error Recovery**: Robust error handling with detailed logging for troubleshooting + +### Technical Details +- **Drupal Version**: Compatible with Drupal 11 +- **Dependencies**: group, ldap_servers +- **Architecture**: Service-based with dependency injection +- **Configuration**: YAML-based with dynamic form interface +- **Security**: Defensive coding practices with proper input validation + +### Configuration +- LDAP server selection with status verification +- Query selection filtered by server with real-time updates +- Dynamic attribute mapping with type-specific processing +- Hierarchical organization with parent/child attribute configuration +- User reference mapping with automatic search attribute detection + +[1.0.0]: https://github.com/your-repo/ldap_departments_sync/releases/tag/v1.0.0 \ No newline at end of file diff --git a/ldap_departments_sync/README.md b/ldap_departments_sync/README.md new file mode 100644 index 0000000..2c67df1 --- /dev/null +++ b/ldap_departments_sync/README.md @@ -0,0 +1,184 @@ +# LDAP Departments Sync + +Módulo Drupal para sincronização de departamentos do LDAP com grupos do Drupal. + +## Funcionalidades + +- Sincronização automática de departamentos do LDAP para grupos no Drupal +- Mapeamento flexível de atributos LDAP para campos de grupo +- Sincronização de membros de departamentos baseada em campos de usuário +- Suporte a hierarquia de departamentos +- Mapeamento dinâmico de papéis (roles) nos grupos baseado em campos +- Campos de referência a departamentos reutilizáveis para content types + +## Requisitos + +- Drupal 10.x ou 11.x +- Módulo LDAP (ldap_servers, ldap_query) +- Módulo Group +- Módulo Entity Reference Views Select (opcional, para widgets avançados) +- Módulo Telephone (para campo de telefone) +- Módulo Profile (opcional, para usar field_department em profiles) + +## Instalação + +1. Coloque o módulo no diretório `modules/custom/ldap_departments_sync` +2. Habilite o módulo: `drush en ldap_departments_sync -y` +3. Configure em `/admin/config/ldap/ldap_departments_sync` + +### Instalação da Configuração Padrão + +O módulo inclui configurações opcionais que podem ser instaladas via interface: + +1. Acesse `/admin/config/ldap/ldap_departments_sync` +2. Na seção "Tipo de Grupo", clique em "Instalar Configuração Padrão" +3. Isso criará: + - Tipo de grupo "Departments" + - Campos de departamento (código, sigla, tipo, telefone, sala, e-mail, coordenadores) + - Roles do grupo (admin, member, etc.) + - Displays de visualização e formulário + +## Configuração + +### Mapeamento de Atributos LDAP + +Configure os mapeamentos entre atributos LDAP e campos de grupo em: +`/admin/config/ldap/ldap_departments_sync` + +Exemplo de configuração padrão: +- `imeccDepartmentCode` → `field_dept_code` +- `description` → `label` +- `cn` → `field_dept_acronym` +- `imeccDepartmentType` → `field_dept_type` +- `imeccDepartmentCoord` → `field_dept_coord` (referência a usuário) +- `imeccDepartmentAssocCoord` → `field_dept_coord_assoc` (referência a usuário) + +### Sincronização de Membros + +O módulo pode sincronizar automaticamente membros para os grupos baseado em: +- Matching entre `field_user_dept_code` (usuário) e `field_dept_code` (grupo) +- Atribuição automática de roles baseado em campos de usuário + +### Hierarquia de Departamentos + +Para habilitar hierarquia: +1. Marque "Enable Hierarchy" +2. Configure: + - **Parent Attribute**: atributo LDAP do pai (ex: `departmentNumber`) + - **Child Attribute**: atributo LDAP do filho para matching (ex: `imeccDepartmentCode`) + +## Adicionando Campos de Referência a Departamentos + +O módulo fornece um **handler de seleção customizado** que facilita a criação de campos de referência a departamentos em qualquer entidade (nodes, users, profiles, etc.). + +### Como Adicionar um Campo de Departamento + +1. Acesse a página de gerenciamento de campos da entidade desejada: + - **Content Types**: `Structure > Content types > [Tipo] > Manage fields` + - **User**: `Configuration > People > Account settings > Manage fields` + - **Profiles**: `Configuration > People > Profile types > [Tipo] > Manage fields` + +2. Clique em **"Create a new field"** + +3. Em **"Add a new field"**: + - Selecione **"Reference"** como categoria + - Escolha **"Other..."** (ou "Outro...") + - Preencha o **Label** (ex: "Departamento", "Department", "Setor", etc.) + - Clique em **"Continue"** + +4. Configure o field storage: + - **Type of item to reference**: Selecione **"Group"** (Grupo) + - Clique em **"Save field settings"** + +5. Configure as definições do campo: + - **Reference method**: Selecione **"Department selection"** + - **Group type**: Automaticamente pré-selecionado como **"Departments"** + - **Filter by department type**: (Opcional) Marque para filtrar por tipo + - **Allowed department types**: (Opcional) Selecione tipos permitidos: + - ☐ Acadêmico + - ☐ Administrativo + - Configure outras opções conforme necessário: + - **Required**: Se o campo é obrigatório + - **Help text**: Texto de ajuda + - **Default value**: Valor padrão + - Clique em **"Save settings"** + +### Vantagens desta Abordagem + +- ✅ **Nomes customizáveis**: Escolha qualquer machine name para o campo +- ✅ **Múltiplos campos**: Adicione vários campos de departamento no mesmo bundle com propósitos diferentes +- ✅ **Filtragem por tipo**: Opção de filtrar apenas departamentos acadêmicos ou administrativos +- ✅ **Pré-configurado**: O handler "Department selection" já vem configurado com as opções corretas +- ✅ **Flexível**: Use em qualquer tipo de entidade (nodes, users, profiles, custom entities, etc.) + +### Exemplos de Uso + +**Múltiplos campos no mesmo content type:** +``` +Article: + - field_primary_department (obrigatório, todos os tipos) + - field_secondary_department (opcional, todos os tipos) + - field_academic_dept (opcional, apenas acadêmicos) +``` + +**Diferentes configurações por bundle:** +``` +Student Profile: + - field_department (obrigatório, apenas acadêmicos) + +Staff Profile: + - field_department (obrigatório, apenas administrativos) +``` + +## Sincronização Manual + +Execute via Drush: + +```bash +drush ldap-departments-sync +``` + +Ou via interface em `/admin/config/ldap/ldap_departments_sync` clicando em "Sync Now". + +## Sincronização Automática (Cron) + +O módulo sincroniza automaticamente durante a execução do cron do Drupal. + +## Estrutura de Campos do Grupo + +Campos padrão criados para o group type "departments": + +- `field_dept_code` - Código do departamento (texto, obrigatório, único) +- `field_dept_acronym` - Sigla/acrônimo +- `field_dept_type` - Tipo (acadêmico/administrativo) +- `field_dept_phone` - Telefone +- `field_dept_room` - Sala +- `field_dept_mail` - E-mail +- `field_dept_coord` - Coordenador (referência a usuário) +- `field_dept_coord_assoc` - Coordenador associado (referência a usuário) +- `field_parent_group` - Grupo pai (para hierarquia) + +## Campos de Usuário + +Campos necessários no user entity para sincronização de membros: + +- `field_user_dept_code` - Código do departamento do usuário +- `field_user_department` - Referência ao grupo de departamento + +## Desenvolvimento + +### Hooks Disponíveis + +(Documentação futura) + +### API + +(Documentação futura) + +## Licença + +GPL-2.0-or-later + +## Autor + +Desenvolvido para o IME - UNICAMP diff --git a/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml b/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml new file mode 100644 index 0000000..cd747e5 --- /dev/null +++ b/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml @@ -0,0 +1,127 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.field.group.departments.field_dept_acronym + - field.field.group.departments.field_dept_code + - field.field.group.departments.field_dept_coord + - field.field.group.departments.field_dept_coord_assoc + - field.field.group.departments.field_dept_mail + - field.field.group.departments.field_dept_phone + - field.field.group.departments.field_dept_room + - field.field.group.departments.field_dept_type + - field.field.group.departments.field_parent_group + - group.type.departments + module: + - path +id: group.departments.default +targetEntityType: group +bundle: departments +mode: default +content: + field_dept_acronym: + type: string_textfield + weight: 123 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_code: + type: string_textfield + weight: 122 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_coord: + type: entity_reference_autocomplete + weight: 128 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_coord_assoc: + type: entity_reference_autocomplete + weight: 129 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_mail: + type: email_default + weight: 125 + region: content + settings: + placeholder: '' + size: 60 + third_party_settings: { } + field_dept_phone: + type: string_textfield + weight: 124 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_room: + type: string_textfield + weight: 121 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_dept_type: + type: options_select + weight: 127 + region: content + settings: { } + third_party_settings: { } + field_parent_group: + type: entity_reference_autocomplete + weight: 20 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + label: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + langcode: + type: language_select + weight: 2 + region: content + settings: + include_locked: true + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 120 + region: content + settings: + display_label: true + third_party_settings: { } +hidden: + uid: true diff --git a/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml b/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml new file mode 100644 index 0000000..1387873 --- /dev/null +++ b/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml @@ -0,0 +1,107 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.field.group.departments.field_dept_acronym + - field.field.group.departments.field_dept_code + - field.field.group.departments.field_dept_coord + - field.field.group.departments.field_dept_coord_assoc + - field.field.group.departments.field_dept_mail + - field.field.group.departments.field_dept_phone + - field.field.group.departments.field_dept_room + - field.field.group.departments.field_dept_type + - field.field.group.departments.field_parent_group + - group.type.departments + module: + - options +id: group.departments.default +targetEntityType: group +bundle: departments +mode: default +content: + field_dept_acronym: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -2 + region: content + field_dept_code: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -3 + region: content + field_dept_coord: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 22 + region: content + field_dept_coord_assoc: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 23 + region: content + field_dept_mail: + type: basic_string + label: above + settings: { } + third_party_settings: { } + weight: 0 + region: content + field_dept_phone: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -1 + region: content + field_dept_room: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -4 + region: content + field_dept_type: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 21 + region: content + field_parent_group: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 20 + region: content + label: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: { } + weight: -5 + region: content +hidden: + changed: true + created: true + entity_print_view_epub: true + entity_print_view_pdf: true + entity_print_view_word_docx: true + langcode: true + uid: true diff --git a/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml b/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml new file mode 100644 index 0000000..f77a8a9 --- /dev/null +++ b/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml @@ -0,0 +1,68 @@ +langcode: en +status: true +dependencies: + config: + - field.field.user.user.field_user_category + - field.field.user.user.field_user_department + - field.field.user.user.field_user_dept_code + - field.field.user.user.field_user_work_phone + + - field.field.user.user.user_picture + - image.style.thumbnail + module: + - image + - telephone + - user +_core: + default_config_hash: mZLyuWM9CQx2ZJVqFGSbzgFnHzudVbHBYmdU256A5Wk +id: user.user.default +targetEntityType: user +bundle: user +mode: default +content: + field_user_category: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: 3 + region: content + field_user_department: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 10 + region: content + field_user_work_phone: + type: telephone_link + label: above + settings: + title: '' + third_party_settings: { } + weight: 4 + region: content + member_for: + settings: { } + third_party_settings: { } + weight: 1 + region: content + user_picture: + type: image + label: hidden + settings: + image_link: content + image_style: thumbnail + image_loading: + attribute: lazy + third_party_settings: { } + weight: 0 + region: content +hidden: + entity_print_view_epub: true + entity_print_view_pdf: true + entity_print_view_word_docx: true + field_user_dept_code: true + langcode: true diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml new file mode 100644 index 0000000..efc6dc6 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_acronym + - group.type.departments +id: group.departments.field_dept_acronym +field_name: field_dept_acronym +entity_type: group +bundle: departments +label: Sigla +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml new file mode 100644 index 0000000..3cdfcbf --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_code + - group.type.departments +id: group.departments.field_dept_code +field_name: field_dept_code +entity_type: group +bundle: departments +label: Código +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml new file mode 100644 index 0000000..e3f7d9c --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml @@ -0,0 +1,28 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_coord + - group.type.departments +id: group.departments.field_dept_coord +field_name: field_dept_coord +entity_type: group +bundle: departments +label: Coordenador +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:user' + handler_settings: + target_bundles: null + sort: + field: _none + direction: ASC + auto_create: false + filter: + type: _none + include_anonymous: false +field_type: entity_reference diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml new file mode 100644 index 0000000..5cf8297 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml @@ -0,0 +1,28 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_coord_assoc + - group.type.departments +id: group.departments.field_dept_coord_assoc +field_name: field_dept_coord_assoc +entity_type: group +bundle: departments +label: 'Coordenador Associado' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:user' + handler_settings: + target_bundles: null + sort: + field: _none + direction: ASC + auto_create: false + filter: + type: _none + include_anonymous: false +field_type: entity_reference diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml new file mode 100644 index 0000000..7396a1e --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_mail + - group.type.departments +id: group.departments.field_dept_mail +field_name: field_dept_mail +entity_type: group +bundle: departments +label: Email +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: email diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml new file mode 100644 index 0000000..340544e --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_phone + - group.type.departments +id: group.departments.field_dept_phone +field_name: field_dept_phone +entity_type: group +bundle: departments +label: Telefone +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml new file mode 100644 index 0000000..a1a25c0 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_dept_room + - group.type.departments +id: group.departments.field_dept_room +field_name: field_dept_room +entity_type: group +bundle: departments +label: Sala +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml new file mode 100644 index 0000000..c786c54 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.group.field_dept_type + - group.type.departments + module: + - options +id: group.departments.field_dept_type +field_name: field_dept_type +entity_type: group +bundle: departments +label: 'Tipo' +description: 'Tipo do departamento (Administrativo ou Acadêmico)' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml new file mode 100644 index 0000000..ff63a4d --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml @@ -0,0 +1,27 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_parent_group + - group.type.departments +id: group.departments.field_parent_group +field_name: field_parent_group +entity_type: group +bundle: departments +label: 'Órgão superior' +description: 'Grupo pai na hierarquia de departamentos' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:group' + handler_settings: + target_bundles: + departments: departments + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml new file mode 100644 index 0000000..d38621d --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.user.field_user_category + module: + - user +id: user.user.field_user_category +field_name: field_user_category +entity_type: user +bundle: user +label: Category +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml new file mode 100644 index 0000000..bcbcd4d --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml @@ -0,0 +1,24 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.user.field_user_department + module: + - user +id: user.user.field_user_department +field_name: field_user_department +entity_type: user +bundle: user +label: Departamento +description: 'Departamento do usuário sincronizado do LDAP' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: views + handler_settings: + view: + view_name: departments + display_name: entity_reference_1 +field_type: entity_reference diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml new file mode 100644 index 0000000..b49c8ab --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.user.field_user_dept_code + module: + - user +id: user.user.field_user_dept_code +field_name: field_user_dept_code +entity_type: user +bundle: user +label: 'Department Code' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml new file mode 100644 index 0000000..3aa05c0 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.user.field_user_work_phone + module: + - telephone + - user +id: user.user.field_user_work_phone +field_name: field_user_work_phone +entity_type: user +bundle: user +label: 'Work Phone' +description: 'Work phone number, populated from LDAP.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: telephone diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml new file mode 100644 index 0000000..192f7dc --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_dept_acronym +field_name: field_dept_acronym +entity_type: group +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml new file mode 100644 index 0000000..003bf42 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_dept_code +field_name: field_dept_code +entity_type: group +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml new file mode 100644 index 0000000..aec2d39 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml @@ -0,0 +1,19 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - user +id: group.field_dept_coord +field_name: field_dept_coord +entity_type: group +type: entity_reference +settings: + target_type: user +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml new file mode 100644 index 0000000..957fac0 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml @@ -0,0 +1,19 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - user +id: group.field_dept_coord_assoc +field_name: field_dept_coord_assoc +entity_type: group +type: entity_reference +settings: + target_type: user +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml new file mode 100644 index 0000000..85b6b97 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_dept_mail +field_name: field_dept_mail +entity_type: group +type: email +settings: { } +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml new file mode 100644 index 0000000..73651c5 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_dept_phone +field_name: field_dept_phone +entity_type: group +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml new file mode 100644 index 0000000..931ab0a --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_dept_room +field_name: field_dept_room +entity_type: group +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml new file mode 100644 index 0000000..cb9cf82 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml @@ -0,0 +1,26 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - options +id: group.field_dept_type +field_name: field_dept_type +entity_type: group +type: list_string +settings: + allowed_values: + - + value: academico + label: Acadêmico + - + value: administrativo + label: Administrativo + allowed_values_function: '' +module: options +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml b/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml new file mode 100644 index 0000000..9745cda --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_parent_group +field_name: field_parent_group +entity_type: group +type: entity_reference +settings: + target_type: group +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml new file mode 100644 index 0000000..dcfc5ea --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - user +id: user.field_user_category +field_name: field_user_category +entity_type: user +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml new file mode 100644 index 0000000..5d044df --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - group + - user +id: user.field_user_department +field_name: field_user_department +entity_type: user +type: entity_reference +settings: + target_type: group +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml new file mode 100644 index 0000000..1348ae0 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - user +id: user.field_user_dept_code +field_name: field_user_dept_code +entity_type: user +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml new file mode 100644 index 0000000..7d58684 --- /dev/null +++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + module: + - telephone + - user +id: user.field_user_work_phone +field_name: field_user_work_phone +entity_type: user +type: telephone +settings: { } +module: telephone +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin.yml b/ldap_departments_sync/config/optional/group.role.departments-admin.yml new file mode 100644 index 0000000..0122561 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-admin.yml @@ -0,0 +1,45 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments +id: departments-admin +label: Admin +weight: 100 +admin: true +scope: individual +global_role: null +group_type: departments +permissions: + - administer members + - delete group + - edit group + - leave group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - create group_node:webform entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete any group_node:webform entity + - delete own group_node:article entity + - delete own group_node:page entity + - delete own group_node:webform entity + - update any group_node:article entity + - update any group_node:page entity + - update any group_node:webform entity + - update own group_node:article entity + - update own group_node:page entity + - update own group_node:webform entity + - view group_node:article entity + - view group_node:page entity + - view group_node:webform entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view own unpublished group_node:webform entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity + - view unpublished group_node:webform entity diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml b/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml new file mode 100644 index 0000000..3070fe0 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml @@ -0,0 +1,46 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments + - user.role.administrator +id: departments-admin_in +label: Administrador +weight: 102 +admin: true +scope: insider +global_role: administrator +group_type: departments +permissions: + - administer members + - delete group + - edit group + - leave group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - create group_node:webform entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete any group_node:webform entity + - delete own group_node:article entity + - delete own group_node:page entity + - delete own group_node:webform entity + - update any group_node:article entity + - update any group_node:page entity + - update any group_node:webform entity + - update own group_node:article entity + - update own group_node:page entity + - update own group_node:webform entity + - view group_node:article entity + - view group_node:page entity + - view group_node:webform entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view own unpublished group_node:webform entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity + - view unpublished group_node:webform entity diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml b/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml new file mode 100644 index 0000000..b8b1318 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml @@ -0,0 +1,46 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments + - user.role.administrator +id: departments-admin_out +label: Administrador +weight: 101 +admin: true +scope: outsider +global_role: administrator +group_type: departments +permissions: + - administer members + - delete group + - edit group + - join group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - create group_node:webform entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete any group_node:webform entity + - delete own group_node:article entity + - delete own group_node:page entity + - delete own group_node:webform entity + - update any group_node:article entity + - update any group_node:page entity + - update any group_node:webform entity + - update own group_node:article entity + - update own group_node:page entity + - update own group_node:webform entity + - view group_node:article entity + - view group_node:page entity + - view group_node:webform entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view own unpublished group_node:webform entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity + - view unpublished group_node:webform entity diff --git a/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml b/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml new file mode 100644 index 0000000..20c7c45 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments + - user.role.anonymous +id: departments-anonymous +label: Anônimo +weight: -102 +admin: false +scope: outsider +global_role: anonymous +group_type: departments +permissions: + - view group + - view group_node:article entity + - view group_node:page entity diff --git a/ldap_departments_sync/config/optional/group.role.departments-member.yml b/ldap_departments_sync/config/optional/group.role.departments-member.yml new file mode 100644 index 0000000..26310e1 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-member.yml @@ -0,0 +1,36 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments + - user.role.authenticated +id: departments-member +label: Member +weight: -100 +admin: false +scope: insider +global_role: authenticated +group_type: departments +permissions: + - view group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - create group_node:webform entity + - delete own group_node:article entity + - delete own group_node:page entity + - delete own group_node:webform entity + - update any group_node:webform entity + - update own group_node:article entity + - update own group_node:page entity + - update own group_node:webform entity + - view group_node:article entity + - view group_node:page entity + - view group_node:webform entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view own unpublished group_node:webform entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity + - view unpublished group_node:webform entity diff --git a/ldap_departments_sync/config/optional/group.role.departments-outsider.yml b/ldap_departments_sync/config/optional/group.role.departments-outsider.yml new file mode 100644 index 0000000..fc87df0 --- /dev/null +++ b/ldap_departments_sync/config/optional/group.role.departments-outsider.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.departments + - user.role.authenticated +id: departments-outsider +label: Outsider +weight: -101 +admin: false +scope: outsider +global_role: authenticated +group_type: departments +permissions: + - view group + - view group_node:article entity + - view group_node:page entity diff --git a/ldap_departments_sync/config/optional/group.type.departments.yml b/ldap_departments_sync/config/optional/group.type.departments.yml new file mode 100644 index 0000000..ef2603d --- /dev/null +++ b/ldap_departments_sync/config/optional/group.type.departments.yml @@ -0,0 +1,10 @@ +langcode: pt-br +status: true +dependencies: { } +id: departments +label: Departamento +description: '' +new_revision: true +creator_membership: true +creator_wizard: true +creator_roles: { } diff --git a/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml b/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml new file mode 100644 index 0000000..b6d463d --- /dev/null +++ b/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml @@ -0,0 +1,2 @@ +label: 'Telefone Profissional' +description: 'Número de telefone profissional, populado via LDAP.' diff --git a/ldap_departments_sync/css/role-mapping.css b/ldap_departments_sync/css/role-mapping.css new file mode 100644 index 0000000..f5f17b1 --- /dev/null +++ b/ldap_departments_sync/css/role-mapping.css @@ -0,0 +1,73 @@ +/** + * Role mapping table styles + */ + +/* Limita a largura da tabela e permite scroll horizontal se necessário */ +.role-mappings-table { + max-width: 100%; + table-layout: fixed; +} + +/* Define larguras fixas para cada coluna */ +.role-mappings-table th:nth-child(1), +.role-mappings-table td:nth-child(1) { + width: 20%; + min-width: 150px; +} + +.role-mappings-table th:nth-child(2), +.role-mappings-table td:nth-child(2) { + width: 15%; + min-width: 130px; +} + +.role-mappings-table th:nth-child(3), +.role-mappings-table td:nth-child(3) { + width: 25%; + min-width: 180px; +} + +.role-mappings-table th:nth-child(4), +.role-mappings-table td:nth-child(4) { + width: 30%; + min-width: 200px; +} + +.role-mappings-table th:nth-child(5), +.role-mappings-table td:nth-child(5) { + width: 10%; + min-width: 80px; + text-align: center; +} + +/* Estilo dos selects para limitar largura e truncar texto */ +.role-mapping-field-select { + max-width: 100%; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Estilo para options dentro dos selects - mostra texto completo no dropdown */ +.role-mapping-field-select option { + white-space: normal; + overflow: visible; + text-overflow: clip; +} + +/* Estilo do textarea */ +.role-mapping-values-textarea { + max-width: 100%; + width: 100%; + min-height: 50px; +} + +/* Responsividade: em telas menores, permite scroll horizontal */ +@media (max-width: 1200px) { + .role-mappings-table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} diff --git a/ldap_departments_sync/ldap_departments_sync.info.yml b/ldap_departments_sync/ldap_departments_sync.info.yml new file mode 100644 index 0000000..5294447 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.info.yml @@ -0,0 +1,13 @@ +name: LDAP Departments Sync +type: module +description: 'Sincroniza departamentos do servidor LDAP com grupos via cron' +version: '1.1.0' +core_version_requirement: ^11 +package: Custom +dependencies: + - ldap_groups_sync:ldap_groups_sync + - drupal:options + - drupal:telephone + - group:group + - ldap:ldap_servers + - site_tools diff --git a/ldap_departments_sync/ldap_departments_sync.install b/ldap_departments_sync/ldap_departments_sync.install new file mode 100644 index 0000000..f78e04a --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.install @@ -0,0 +1,106 @@ +addWarning(t('Please ensure your group type has the required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail, and field_parent_group (for hierarchy).')); +} + +/** + * Renames field_user_phone_number to field_user_work_phone. + */ +function ldap_departments_sync_update_10001() { + // 1. Create new field storage. + if (!FieldStorageConfig::loadByName('user', 'field_user_work_phone')) { + FieldStorageConfig::create([ + 'field_name' => 'field_user_work_phone', + 'entity_type' => 'user', + 'type' => 'telephone', + 'cardinality' => 1, + 'translatable' => TRUE, + ])->save(); + } + + // 2. Create new field instance. + if (!FieldConfig::loadByName('user', 'user', 'field_user_work_phone')) { + FieldConfig::create([ + 'field_name' => 'field_user_work_phone', + 'entity_type' => 'user', + 'bundle' => 'user', + 'label' => 'Work Phone', + 'description' => 'Work phone number, populated from LDAP.', + 'required' => FALSE, + 'translatable' => FALSE, + ])->save(); + } + + // 3. Copy data directly via SQL — efficient and side-effect-free. + $database = \Drupal::database(); + $schema = $database->schema(); + foreach (['user__', 'user_revision__'] as $prefix) { + $old_table = $prefix . 'field_user_phone_number'; + $new_table = $prefix . 'field_user_work_phone'; + if ($schema->tableExists($old_table) && $schema->tableExists($new_table)) { + $database->query(" + INSERT INTO {{$new_table}} + (bundle, deleted, entity_id, revision_id, langcode, delta, field_user_work_phone_value) + SELECT bundle, deleted, entity_id, revision_id, langcode, delta, field_user_phone_number_value + FROM {{$old_table}} + WHERE deleted = 0 + "); + } + } + + // 4. Update form display. + $form_display = EntityFormDisplay::load('user.user.default'); + if ($form_display) { + $component = $form_display->getComponent('field_user_phone_number'); + if ($component) { + $form_display->removeComponent('field_user_phone_number'); + $form_display->setComponent('field_user_work_phone', $component); + $form_display->save(); + } + } + + // 5. Update view display. + $view_display = EntityViewDisplay::load('user.user.default'); + if ($view_display) { + $component = $view_display->getComponent('field_user_phone_number'); + if ($component) { + $view_display->removeComponent('field_user_phone_number'); + $view_display->setComponent('field_user_work_phone', $component); + $view_display->save(); + } + } + + // 6. Delete old field instance then storage. + if ($old_config = FieldConfig::loadByName('user', 'user', 'field_user_phone_number')) { + $old_config->delete(); + } + if ($old_storage = FieldStorageConfig::loadByName('user', 'field_user_phone_number')) { + $old_storage->delete(); + } + + return t('Renamed field_user_phone_number to field_user_work_phone.'); +} + +/** + * Implements hook_uninstall(). + */ +function ldap_departments_sync_uninstall() { + // Remove configurações + \Drupal::configFactory()->getEditable('ldap_departments_sync.settings')->delete(); + + \Drupal::messenger()->addStatus(t('Módulo LDAP Departments Sync desinstalado.')); +} diff --git a/ldap_departments_sync/ldap_departments_sync.libraries.yml b/ldap_departments_sync/ldap_departments_sync.libraries.yml new file mode 100644 index 0000000..da24478 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.libraries.yml @@ -0,0 +1,5 @@ +role_mapping_styles: + version: 1.x + css: + theme: + css/role-mapping.css: {} diff --git a/ldap_departments_sync/ldap_departments_sync.links.menu.yml b/ldap_departments_sync/ldap_departments_sync.links.menu.yml new file mode 100644 index 0000000..83f4e0c --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.links.menu.yml @@ -0,0 +1,6 @@ +ldap_departments_sync.config: + title: 'LDAP Departments Sync' + description: 'Configurar sincronização de departamentos do LDAP' + route_name: ldap_departments_sync.config + parent: site_tools.admin_config + weight: 5 diff --git a/ldap_departments_sync/ldap_departments_sync.links.task.yml b/ldap_departments_sync/ldap_departments_sync.links.task.yml new file mode 100644 index 0000000..a8a4334 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.links.task.yml @@ -0,0 +1,11 @@ +ldap_departments_sync.tab.config: + title: 'Configuration' + route_name: ldap_departments_sync.config + base_route: ldap_departments_sync.config + weight: 0 + +ldap_departments_sync.tab.access_rules: + title: 'Access Rules' + route_name: ldap_departments_sync.access_rules + base_route: ldap_departments_sync.config + weight: 10 diff --git a/ldap_departments_sync/ldap_departments_sync.module b/ldap_departments_sync/ldap_departments_sync.module new file mode 100644 index 0000000..e1a0656 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.module @@ -0,0 +1,291 @@ +' . t('This module synchronizes departments from an LDAP server to groups through cron.') . '

'; + } +} + +/** + * Implements hook_cron(). + */ +function ldap_departments_sync_cron() { + // Check if module is properly configured + $config = \Drupal::config('ldap_departments_sync.settings'); + $ldap_server_id = $config->get('ldap_server_id'); + $ldap_query_id = $config->get('ldap_query_id'); + + // Validate LDAP server + try { + $entity_type_manager = \Drupal::entityTypeManager(); + $server_storage = $entity_type_manager->getStorage('ldap_server'); + $server = $server_storage->load($ldap_server_id); + + if (!$server || !$server->get('status')) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: LDAP server "@server_id" not found or inactive. Configure at /admin/config/local-modules/ldap-departments-sync', [ + '@server_id' => $ldap_server_id, + ]); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: error checking LDAP server: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Validate LDAP query + try { + if ($entity_type_manager->hasDefinition('ldap_query_entity')) { + $query_storage = $entity_type_manager->getStorage('ldap_query_entity'); + $query = $query_storage->load($ldap_query_id); + + if (!$query || !$query->get('status')) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: LDAP query "@query_id" not found or inactive. Configure at /admin/config/local-modules/ldap-departments-sync', [ + '@query_id' => $ldap_query_id, + ]); + return; + } + } + else { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: ldap_query module not available.'); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: error checking LDAP query: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Validate group type + try { + $group_type_id = $config->get('group_type_id'); + $group_type_storage = $entity_type_manager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if (!$group_type) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: group type "@type_id" not found. Configure at /admin/config/local-modules/ldap-departments-sync', [ + '@type_id' => $group_type_id, + ]); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: error checking group type: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Get LDAP synchronization service + $ldap_sync = \Drupal::service('ldap_departments_sync.sync'); + + // Execute departments synchronization + try { + $ldap_sync->syncDepartments(); + \Drupal::logger('ldap_departments_sync')->info('Departments synchronization executed successfully.'); + } + catch (\Exception $e) { + \Drupal::logger('ldap_departments_sync')->error('Error in departments synchronization: @message', [ + '@message' => $e->getMessage(), + ]); + } +} + +/** + * Implements hook_entity_access(). + * + * Evaluates access rules configured in the module settings for view, update + * and delete operations on existing entities. + */ +function ldap_departments_sync_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { + // Skip group-related entities to avoid conflicts with the group module. + $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type']; + if (in_array($entity->getEntityTypeId(), $skip_types, TRUE)) { + return AccessResult::neutral(); + } + + return \Drupal::service('ldap_departments_sync.access_rules') + ->checkAccess($entity, $operation, $account); +} + +/** + * Implements hook_entity_create_access(). + * + * Evaluates access rules configured in the module settings for create + * operations on new entities. + */ +function ldap_departments_sync_entity_create_access(AccountInterface $account, array $context, $entity_bundle) { + $entity_type_id = $context['entity_type_id'] ?? ''; + + // Skip group-related entities. + $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type']; + if (in_array($entity_type_id, $skip_types, TRUE)) { + return AccessResult::neutral(); + } + + return \Drupal::service('ldap_departments_sync.access_rules') + ->checkCreateAccess($account, $entity_type_id, $entity_bundle ?? ''); +} + +/** + * Implements hook_entity_field_access(). + * + * Nega acesso de edição aos campos gerenciados pelo LDAP sync em formulários + * e APIs (REST, JSON:API). Saves programáticos não são afetados. + * + * Campos protegidos no usuário: + * - field_user_category: categoria do usuário, populado pelo ldap_user. + * - field_user_dept_code: código do departamento, populado pelo ldap_user. + * - field_user_department: referência ao grupo, populado pelo nosso sync. + * Proteger este campo evita o erro de validação "Esta entidade não pode + * ser referenciada" que ocorre quando o widget tenta validar o grupo + * referenciado contra o acesso do usuário atual. + * - field_user_work_phone: telefone de trabalho, populado pelo ldap_user. + */ +function ldap_departments_sync_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) { + $protected_user_fields = [ + 'field_user_category', + 'field_user_dept_code', + 'field_user_department', + 'field_user_work_phone', + ]; + + if ( + $field_definition->getTargetEntityTypeId() === 'user' && + in_array($field_definition->getName(), $protected_user_fields, TRUE) && + $operation === 'edit' + ) { + // Sempre nega edição via UI/API, inclusive para o uid 1. + // Saves programáticos (ldap_user, drush) não passam por este hook. + return AccessResult::forbidden('This field is managed exclusively by LDAP synchronization.'); + } + return AccessResult::neutral(); +} + +/** + * Implements hook_user_presave(). + * + * Protege os campos gerenciados pelo LDAP sync de alterações programáticas + * não autorizadas em usuários existentes. Permite: + * - Saves durante sync LDAP autorizado (LdapDepartmentsSync::$syncing TRUE) + * - Criação de novos usuários (provisionamento inicial via ldap_user) + * - Salvas por usuários com a permissão 'edit ldap managed user fields' + */ +function ldap_departments_sync_user_presave(\Drupal\user\UserInterface $user) { + // Permite durante qualquer sync LDAP autorizado. + if (LdapDepartmentsSync::isSyncing()) { + return; + } + + // Permite na criação inicial do usuário (primeiro login LDAP). + if ($user->isNew()) { + return; + } + + // Permite se o usuário atual tem a permissão de bypass. + if (\Drupal::currentUser()->hasPermission('edit ldap managed user fields')) { + return; + } + + $original = $user->original; + if ($original === NULL) { + return; + } + + // Campos gerenciados pelo LDAP que não podem ser alterados externamente. + $protected_fields = [ + 'field_user_category', + 'field_user_dept_code', + 'field_user_department', + 'field_user_work_phone', + ]; + + foreach ($protected_fields as $field_name) { + if (!$user->hasField($field_name)) { + continue; + } + + $original_value = $original->get($field_name)->getValue(); + $new_value = $user->get($field_name)->getValue(); + + if ($original_value !== $new_value) { + $user->set($field_name, $original_value); + \Drupal::logger('ldap_departments_sync')->warning( + 'Tentativa não autorizada de alterar @field do usuário @username foi bloqueada. Use a sincronização LDAP para atualizar este campo.', + [ + '@field' => $field_name, + '@username' => $user->getAccountName(), + ] + ); + } + } +} + +/** + * Implements hook_ENTITY_TYPE_predelete() for user entities. + * + * Cleans up user references in department groups when a user is deleted. + */ +function ldap_departments_sync_user_predelete(\Drupal\user\UserInterface $user) { + $config = \Drupal::config('ldap_departments_sync.settings'); + $group_type_id = $config->get('group_type_id') ?: 'departments'; + + // Fields that may contain user references + $user_reference_fields = [ + 'field_dept_coord', + 'field_dept_coord_assoc', + ]; + + try { + $group_storage = \Drupal::entityTypeManager()->getStorage('group'); + $user_id = $user->id(); + + // Find groups that reference this user + foreach ($user_reference_fields as $field_name) { + $groups = $group_storage->loadByProperties([ + 'type' => $group_type_id, + $field_name => $user_id, + ]); + + foreach ($groups as $group) { + if ($group->hasField($field_name)) { + $group->set($field_name, NULL); + $group->save(); + \Drupal::logger('ldap_departments_sync')->info('Removed user @username reference from @field in group @group', [ + '@username' => $user->getAccountName(), + '@field' => $field_name, + '@group' => $group->label(), + ]); + } + } + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_departments_sync')->error('Error cleaning up user references: @message', [ + '@message' => $e->getMessage(), + ]); + } +} diff --git a/ldap_departments_sync/ldap_departments_sync.permissions.yml b/ldap_departments_sync/ldap_departments_sync.permissions.yml new file mode 100644 index 0000000..784654f --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.permissions.yml @@ -0,0 +1,9 @@ +administer ldap departments sync: + title: 'Administrar LDAP Departments Sync' + description: 'Configurar sincronização de departamentos do LDAP' + restrict access: true + +edit ldap managed user fields: + title: 'Editar campos gerenciados pelo LDAP no usuário' + description: 'Permite editar manualmente campos como field_user_dept_code que são normalmente gerenciados pelo LDAP. Use apenas em emergências.' + restrict access: true \ No newline at end of file diff --git a/ldap_departments_sync/ldap_departments_sync.routing.yml b/ldap_departments_sync/ldap_departments_sync.routing.yml new file mode 100644 index 0000000..f208140 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.routing.yml @@ -0,0 +1,25 @@ +ldap_departments_sync.config: + path: '/admin/config/local-modules/ldap-departments-sync' + defaults: + _form: '\Drupal\ldap_departments_sync\Form\LdapDepartmentsSyncConfigForm' + _title: 'LDAP Departments Sync' + requirements: + _permission: 'administer ldap departments sync' + +ldap_departments_sync.access_rules: + path: '/admin/config/local-modules/ldap-departments-sync/access-rules' + defaults: + _form: '\Drupal\ldap_departments_sync\Form\AccessRulesForm' + _title: 'Access Rules' + requirements: + _permission: 'administer ldap departments sync' + +ldap_departments_sync.access_rule_form: + path: '/admin/config/local-modules/ldap-departments-sync/access-rule/{rule_index}' + defaults: + _form: '\Drupal\ldap_departments_sync\Form\AccessRuleForm' + _title: 'Access Rule' + rule_index: 'new' + requirements: + _permission: 'administer ldap departments sync' + rule_index: 'new|\d+' diff --git a/ldap_departments_sync/ldap_departments_sync.services.yml b/ldap_departments_sync/ldap_departments_sync.services.yml new file mode 100644 index 0000000..2495aa8 --- /dev/null +++ b/ldap_departments_sync/ldap_departments_sync.services.yml @@ -0,0 +1,8 @@ +services: + ldap_departments_sync.sync: + class: Drupal\ldap_departments_sync\LdapDepartmentsSync + arguments: ['@entity_type.manager', '@config.factory', '@logger.factory', '@ldap.bridge'] + + ldap_departments_sync.access_rules: + class: Drupal\ldap_groups_sync\GroupAccessRulesService + arguments: ['@config.factory', '@entity_type.manager', 'ldap_departments_sync.settings'] diff --git a/ldap_departments_sync/src/Controller/LocalModulesController.php b/ldap_departments_sync/src/Controller/LocalModulesController.php new file mode 100644 index 0000000..e21f05a --- /dev/null +++ b/ldap_departments_sync/src/Controller/LocalModulesController.php @@ -0,0 +1,22 @@ +t('Local Modules'); + } + +} \ No newline at end of file diff --git a/ldap_departments_sync/src/Form/AccessRuleForm.php b/ldap_departments_sync/src/Form/AccessRuleForm.php new file mode 100644 index 0000000..0013c0f --- /dev/null +++ b/ldap_departments_sync/src/Form/AccessRuleForm.php @@ -0,0 +1,40 @@ +entityTypeManager = $entity_type_manager; + $this->ldapDepartmentsSync = $ldap_departments_sync; + $this->messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('ldap_departments_sync.sync'), + $container->get('messenger') + ); + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['ldap_departments_sync.settings']; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'ldap_departments_sync_config_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config('ldap_departments_sync.settings'); + + // Group Type + $form['group_type'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Group Type'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Get list of group types + $group_types = $this->getGroupTypes(); + + $form['group_type']['group_type_id'] = [ + '#type' => 'select', + '#title' => $this->t('Target Group Type'), + '#description' => $this->t('Select the group type where departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail'), + '#options' => $group_types, + '#empty_option' => $this->t('- Select a group type -'), + '#default_value' => $config->get('group_type_id') ?: 'departments', + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateGroupTypeActions', + 'wrapper' => 'group-type-wrapper', + 'effect' => 'fade', + 'disable-refocus' => TRUE, + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ], + ]; + + $form['group_type']['group_type_actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions', 'group-type-actions']], + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Verificar se o tipo de grupo "departments" existe + $departments_exists = isset($group_types['departments']); + + if (empty($group_types) || !$departments_exists) { + $form['group_type']['setup_notice'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('The default "departments" group type is not installed.') . + '
', + '#weight' => -10, + ]; + + $form['group_type']['group_type_actions']['install_defaults'] = [ + '#type' => 'submit', + '#value' => $this->t('Install Default Configuration'), + '#submit' => ['::installDefaultConfiguration'], + '#button_type' => 'primary', + '#limit_validation_errors' => [], + '#attributes' => ['class' => ['button', 'button--primary']], + ]; + + $form['group_type']['install_help'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('The button above will create:
    +
  • Group type "departments" with all required fields
  • +
  • User fields for department synchronization
  • +
  • Default group roles and displays
  • +
Or you can create a custom group type manually.', [ + '@url' => '/admin/group/types/add', + ]) . '
', + '#weight' => 100, + ]; + } + + $form['group_type']['group_type_actions']['create_group_type'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Create New Group Type') . ' ', + ]; + + // Button to edit selected group type + $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'departments'; + if (!empty($selected_group_type)) { + $form['group_type']['group_type_actions']['edit_group_type'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Manage Group Type') . ' ', + ]; + + $form['group_type']['group_type_actions']['edit_group_type_fields'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Manage Fields') . '', + ]; + } + + // LDAP Server + $form['ldap_server'] = [ + '#type' => 'fieldset', + '#title' => $this->t('LDAP Server'), + '#collapsible' => FALSE, + ]; + + // Get list of LDAP servers + $ldap_servers = $this->getLdapServers(); + + $form['ldap_server']['ldap_server_id'] = [ + '#type' => 'select', + '#title' => $this->t('LDAP Server'), + '#description' => $this->t('Select the LDAP server configured for departments synchronization.'), + '#options' => $ldap_servers, + '#empty_option' => $this->t('- Select a server -'), + '#default_value' => $config->get('ldap_server_id'), + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateLdapQueries', + 'wrapper' => 'ldap-queries-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ], + ]; + + if (empty($ldap_servers)) { + $form['ldap_server']['no_servers'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('No LDAP server found. Configure an LDAP server first.', [ + '@url' => '/admin/config/people/ldap/servers', + ]) . '
', + ]; + } + + // LDAP Query + $form['ldap_query'] = [ + '#type' => 'fieldset', + '#title' => $this->t('LDAP Query'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id'); + $ldap_queries = $this->getLdapQueriesForServer($selected_server); + + $form['ldap_query']['ldap_query_id'] = [ + '#type' => 'select', + '#title' => $this->t('LDAP Query'), + '#description' => $this->t('Select the LDAP query that will be used to search for departments.'), + '#options' => $ldap_queries, + '#empty_option' => $this->t('- Select a query -'), + '#default_value' => $config->get('ldap_query_id'), + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateLdapQueries', + 'wrapper' => 'ldap-queries-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ['ldap_query_id'], + ], + ]; + + if (empty($ldap_queries) && !empty($selected_server)) { + $form['ldap_query']['no_queries'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('No LDAP query found for the selected server. Create a new LDAP query.', [ + '@url' => '/admin/config/people/ldap/query/add', + ]) . '
', + ]; + } + + if (!empty($selected_server)) { + $form['ldap_query']['query_actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions', 'query-actions']], + '#prefix' => '
', + '#suffix' => '
', + ]; + + $form['ldap_query']['query_actions']['create_query'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Create New LDAP Query') . ' ', + ]; + + // Button to edit selected query + $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id'); + if (!empty($selected_query)) { + $form['ldap_query']['query_actions']['edit_query'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Edit Selected Query') . '', + ]; + } + } + + // Hierarchy + $form['hierarchy'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Hierarchy'), + '#description' => $this->t('Configure whether departments should be organized hierarchically based on LDAP attributes.'), + '#collapsible' => FALSE, + ]; + + $form['hierarchy']['enable_hierarchy'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable hierarchy'), + '#description' => $this->t('When enabled, terms will be organized hierarchically based on the specified parent and child attributes.'), + '#default_value' => $config->get('enable_hierarchy') ?: FALSE, + '#ajax' => [ + 'callback' => '::updateHierarchyFields', + 'wrapper' => 'hierarchy-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ['ldap_query_id'], + ['enable_hierarchy'], + ], + ]; + + $form['hierarchy']['hierarchy_fields'] = [ + '#type' => 'container', + '#prefix' => '
', + '#suffix' => '
', + ]; + + $enable_hierarchy = $form_state->getValue('enable_hierarchy') ?: $config->get('enable_hierarchy') ?: FALSE; + if ($enable_hierarchy) { + // Get attributes from selected query for hierarchy + $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id'); + $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id'); + $ldap_attributes = $this->getQueryAttributes($selected_server, $selected_query); + + $form['hierarchy']['hierarchy_fields']['parent_attribute'] = [ + '#type' => 'select', + '#title' => $this->t('Parent Attribute'), + '#description' => $this->t('LDAP attribute that contains the parent department code (e.g. departmentNumber).'), + '#options' => $ldap_attributes, + '#empty_option' => $this->t('- Select an attribute -'), + '#default_value' => $config->get('parent_attribute'), + '#required' => FALSE, + ]; + + $form['hierarchy']['hierarchy_fields']['child_attribute'] = [ + '#type' => 'select', + '#title' => $this->t('Child Attribute'), + '#description' => $this->t('LDAP attribute that contains the department\'s own code (e.g. imeccDepartmentCode).'), + '#options' => $ldap_attributes, + '#empty_option' => $this->t('- Select an attribute -'), + '#default_value' => $config->get('child_attribute'), + '#required' => FALSE, + ]; + + $form['hierarchy']['hierarchy_fields']['hierarchy_note'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('All elements with the same child value will be nested under the corresponding parent recursively.') . + '
', + ]; + } + + // Dynamic attribute mapping + $form['attribute_mapping'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Attribute Mapping'), + '#description' => $this->t('Configure how LDAP attributes are mapped to target entity fields.
Note: For "User Reference" mappings, the search attribute will be automatically obtained from the "Account name attribute" field (or "Authentication name attribute" as fallback) configured in the selected LDAP server.'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Get fields from selected group type + $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'departments'; + $entity_fields = $this->getGroupTypeFields($selected_group_type); + + // Get attributes from selected query + $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id'); + $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id'); + $ldap_attributes = $this->getQueryAttributes($selected_server, $selected_query); + + // Check if mappings were just reset by AJAX callback + $mappings_reset = $form_state->get('mappings_reset'); + + // Get existing mappings - prioritize form_state during rebuild + $form_state_mappings = $form_state->getValue('mappings'); + + // If mappings were just reset, use them directly + if ($mappings_reset && !empty($form_state_mappings)) { + $existing_mappings = $form_state_mappings; + \Drupal::logger('ldap_departments_sync')->debug('Using reset mappings: @mappings', [ + '@mappings' => print_r($existing_mappings, TRUE), + ]); + } + elseif (!empty($form_state_mappings)) { + // Filter mappings to only include valid fields for current entity type + $existing_mappings = []; + $valid_fields = array_keys($entity_fields); + + foreach ($form_state_mappings as $mapping) { + // Only include mapping if field exists in current entity type + if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) { + $existing_mappings[] = $mapping; + } + } + + // If no valid mappings remain, use defaults + if (empty($existing_mappings)) { + $existing_mappings = $this->getDefaultMappings(); + } + } else { + $config_mappings = $config->get('attribute_mappings'); + + // Validate config mappings against current entity fields + if (!empty($config_mappings)) { + $valid_mappings = []; + $valid_fields = array_keys($entity_fields); + + foreach ($config_mappings as $mapping) { + if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) { + $valid_mappings[] = $mapping; + } + } + + $existing_mappings = !empty($valid_mappings) ? $valid_mappings : $this->getDefaultMappings(); + } else { + $existing_mappings = $this->getDefaultMappings(); + } + } + + // Mappings table + $form['attribute_mapping']['mappings'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Entity Field'), + $this->t('LDAP Attribute'), + $this->t('Mapping Type'), + $this->t('Remove'), + ], + '#empty' => $this->t('No mappings configured.'), + ]; + + // Add existing rows + $mapping_count = $form_state->get('mapping_count') ?: count($existing_mappings); + $form_state->set('mapping_count', $mapping_count); + + for ($i = 0; $i < $mapping_count; $i++) { + $mapping = isset($existing_mappings[$i]) ? $existing_mappings[$i] : [ + 'field' => '', + 'attribute' => '', + 'mapping_type' => 'simple' + ]; + + // Validate that the field exists in available options + $field_default = isset($mapping['field']) ? $mapping['field'] : ''; + if (!empty($field_default) && !isset($entity_fields[$field_default])) { + $field_default = ''; + } + + // Validate that the attribute exists in available options + $attribute_default = isset($mapping['attribute']) ? $mapping['attribute'] : ''; + if (!empty($attribute_default) && !isset($ldap_attributes[$attribute_default])) { + $attribute_default = ''; + } + + // Build field element + $field_element = [ + '#type' => 'select', + '#options' => $entity_fields, + '#empty_option' => $this->t('- Select a field -'), + '#default_value' => $field_default, + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 200px; width: auto;', + ], + ]; + + // If mappings were reset via AJAX, force the value + if ($mappings_reset && !empty($field_default)) { + $field_element['#value'] = $field_default; + } + + $form['attribute_mapping']['mappings'][$i]['field'] = $field_element; + + $form['attribute_mapping']['mappings'][$i]['attribute'] = [ + '#type' => 'select', + '#options' => $ldap_attributes, + '#empty_option' => $this->t('- Select an attribute -'), + '#default_value' => $attribute_default, + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 180px; width: auto;', + ], + ]; + + $form['attribute_mapping']['mappings'][$i]['mapping_type'] = [ + '#type' => 'select', + '#options' => [ + 'simple' => $this->t('Simple (text)'), + 'user_reference' => $this->t('User Reference (DN → User)'), + ], + '#default_value' => $mapping['mapping_type'] ?? 'simple', + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 150px; width: auto;', + ], + ]; + + $form['attribute_mapping']['mappings'][$i]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + // Clear the mappings_reset flag after using it for all fields + if ($mappings_reset) { + $form_state->set('mappings_reset', FALSE); + } + + // Action buttons + $form['attribute_mapping']['actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions']], + ]; + + $form['attribute_mapping']['actions']['add_mapping'] = [ + '#type' => 'submit', + '#value' => $this->t('Add Mapping'), + '#submit' => ['::addMapping'], + '#ajax' => [ + 'callback' => '::updateAttributeMappings', + 'wrapper' => 'attribute-mapping-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + $form['attribute_mapping']['actions']['remove_selected'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove Selected'), + '#submit' => ['::removeSelectedMappings'], + '#ajax' => [ + 'callback' => '::updateAttributeMappings', + 'wrapper' => 'attribute-mapping-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + // User synchronization + $form['sync_users'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable user department field synchronization'), + '#description' => $this->t('When enabled, the field_user_department on each user will be updated to reference the matching department group based on field_user_dept_code.'), + '#default_value' => $config->get('sync_users') ?: FALSE, + ]; + + // Role Mapping + $form['role_mapping'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Group Role Mapping'), + '#description' => $this->t('Configure how user attributes map to group roles. Each role can have its own mapping criteria.'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $form['role_mapping']['role_mapping_enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable role mapping'), + '#description' => $this->t('When enabled, users will be assigned group roles based on the mappings below. Otherwise, all users will be assigned the default role.'), + '#default_value' => $config->get('role_mapping_enabled') ?: FALSE, + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ['ldap_query_id'], + ['role_mapping_enabled'], + ], + ]; + + $form['role_mapping']['role_mapping_fields'] = [ + '#type' => 'container', + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Check if role mapping is enabled from form state or config + $role_mapping_enabled_value = $form_state->getValue('role_mapping_enabled'); + if ($role_mapping_enabled_value !== NULL) { + $role_mapping_enabled = (bool) $role_mapping_enabled_value; + } else { + $role_mapping_enabled = $config->get('role_mapping_enabled') ?: FALSE; + } + + if ($role_mapping_enabled) { + // Get available group roles + $group_roles = $this->getGroupRoles($selected_group_type); + + // Get LDAP attributes for role mapping (reuse same logic as attribute mapping) + // Note: $ldap_attributes was already defined earlier in the form (line 345) + // But we need to ensure it's available here too + $role_ldap_attributes = $ldap_attributes ?? []; + $user_fields = $this->getUserFields(); + + // Get existing role mappings + $role_form_state_mappings = $form_state->getValue('role_mappings'); + if (!empty($role_form_state_mappings)) { + $existing_role_mappings = $role_form_state_mappings; + } else { + $config_role_mappings = $config->get('role_mappings'); + $existing_role_mappings = $config_role_mappings ?: []; + } + + // Se não há mapeamentos configurados, adiciona o mapeamento padrão de member + if (empty($existing_role_mappings)) { + $existing_role_mappings = [ + [ + 'group_role' => $selected_group_type . '-member', + 'source' => 'group_field_match', + 'source_field' => 'field_user_dept_code', + 'group_field' => 'field_dept_code', + 'values' => '', + ], + ]; + } + + // Role mappings table + $form['role_mapping']['role_mapping_fields']['role_mappings'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Group Role'), + $this->t('Source'), + $this->t('User Field / LDAP Attribute'), + $this->t('Group Field / Values'), + $this->t('Remove'), + ], + '#empty' => $this->t('No role mappings configured. Users will be assigned the default role.'), + '#attributes' => ['class' => ['role-mappings-table']], + '#attached' => [ + 'library' => ['ldap_departments_sync/role_mapping_styles'], + ], + ]; + + $role_mapping_count = $form_state->get('role_mapping_count'); + if ($role_mapping_count === NULL) { + $role_mapping_count = count($existing_role_mappings); + } + $form_state->set('role_mapping_count', $role_mapping_count); + + // Get group fields for "Group Field Match" type + $group_fields = $this->getGroupTypeFields($selected_group_type); + + for ($i = 0; $i < $role_mapping_count; $i++) { + $mapping = isset($existing_role_mappings[$i]) ? $existing_role_mappings[$i] : [ + 'group_role' => '', + 'source' => 'user_field', + 'source_field' => '', + 'group_field' => '', + 'values' => '', + ]; + + // Get current source value to filter fields + $current_source = $form_state->getValue(['role_mappings', $i, 'source']) ?? $mapping['source'] ?? 'user_field'; + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['group_role'] = [ + '#type' => 'select', + '#options' => $group_roles, + '#empty_option' => $this->t('- Select a role -'), + '#default_value' => $mapping['group_role'], + '#required' => FALSE, + ]; + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source'] = [ + '#type' => 'select', + '#options' => [ + 'user_field' => $this->t('User Field'), + 'ldap_attribute' => $this->t('LDAP Attribute'), + 'group_field_match' => $this->t('Group Field Match'), + ], + '#default_value' => $current_source, + '#required' => FALSE, + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + ]; + + // Third column: User Field/LDAP Attribute (changes based on source) + if ($current_source === 'group_field_match') { + // For group_field_match, show user fields only + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [ + '#type' => 'select', + '#options' => $user_fields, + '#empty_option' => $this->t('- Select user field -'), + '#default_value' => $mapping['source_field'], + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } else { + // For fixed value match, show fields based on source type + if ($current_source === 'ldap_attribute') { + $source_options = $role_ldap_attributes; + } else { + $source_options = $user_fields; + } + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [ + '#type' => 'select', + '#options' => $source_options, + '#empty_option' => $this->t('- Select attribute/field -'), + '#default_value' => $mapping['source_field'], + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } + + // Fourth column: Group Field (for group_field_match) or Values (for fixed value match) + if ($current_source === 'group_field_match') { + // Show group field selector + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [ + '#type' => 'select', + '#options' => $group_fields, + '#empty_option' => $this->t('- Select group field -'), + '#default_value' => $mapping['group_field'] ?? '', + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } else { + // Show values textarea + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [ + '#type' => 'textarea', + '#default_value' => is_array($mapping['values']) ? implode(', ', $mapping['values']) : $mapping['values'], + '#placeholder' => $this->t('e.g., Director, Manager'), + '#rows' => 2, + '#resizable' => 'vertical', + '#attributes' => ['class' => ['role-mapping-values-textarea']], + ]; + } + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + // Action buttons for role mapping + $form['role_mapping']['role_mapping_fields']['actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions']], + ]; + + $form['role_mapping']['role_mapping_fields']['actions']['add_role_mapping'] = [ + '#type' => 'submit', + '#value' => $this->t('Add Role Mapping'), + '#submit' => ['::addRoleMapping'], + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + $form['role_mapping']['role_mapping_fields']['actions']['remove_selected_roles'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove Selected'), + '#submit' => ['::removeSelectedRoleMappings'], + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + } + + // Actions + $form['actions'] = [ + '#type' => 'actions', + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save Configuration'), + '#button_type' => 'primary', + ]; + + $form['actions']['test_connection'] = [ + '#type' => 'submit', + '#value' => $this->t('Test Connection'), + '#submit' => ['::testConnection'], + '#limit_validation_errors' => [['ldap_server_id']], + ]; + + $form['actions']['sync_departments'] = [ + '#type' => 'submit', + '#value' => $this->t('Synchronize Departments'), + '#submit' => ['::syncDepartments'], + '#button_type' => 'danger', + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * Gets list of available LDAP servers. + * + * @return array + * Array of LDAP servers [id => label]. + */ + protected function getLdapServers() { + $servers = []; + + try { + $server_storage = $this->entityTypeManager->getStorage('ldap_server'); + $server_entities = $server_storage->loadByProperties(['status' => TRUE]); + + foreach ($server_entities as $server) { + $servers[$server->id()] = $server->label() . ' (' . $server->get('address') . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading LDAP servers: @message', ['@message' => $e->getMessage()])); + } + + return $servers; + } + + + /** + * Gets attributes from the selected LDAP query. + * + * @param string $server_id + * LDAP server ID. + * @param string $query_id + * LDAP query ID. + * + * @return array + * Array of attributes [attribute => attribute]. + */ + protected function getQueryAttributes($server_id, $query_id) { + $attributes = []; + + if (empty($query_id) || !$this->entityTypeManager->hasDefinition('ldap_query_entity')) { + // Return default attributes if no query selected + return [ + 'imeccDepartmentCode' => 'imeccDepartmentCode', + 'description' => 'description', + 'cn' => 'cn', + 'imeccDepartmentType' => 'imeccDepartmentType', + 'telephoneNumber' => 'telephoneNumber', + 'roomNumber' => 'roomNumber', + 'mail' => 'mail', + ]; + } + + try { + $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity'); + $query_entity = $query_storage->load($query_id); + + if ($query_entity) { + $processed_attributes = $query_entity->getProcessedAttributes(); + + if (!empty($processed_attributes)) { + foreach ($processed_attributes as $attribute) { + $attributes[$attribute] = $attribute; + } + } + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading query attributes: @message', ['@message' => $e->getMessage()])); + } + + // If failed to get attributes from query, return defaults + if (empty($attributes)) { + $attributes = [ + 'imeccDepartmentCode' => 'imeccDepartmentCode', + 'description' => 'description', + 'cn' => 'cn', + 'imeccDepartmentType' => 'imeccDepartmentType', + 'telephoneNumber' => 'telephoneNumber', + 'roomNumber' => 'roomNumber', + 'mail' => 'mail', + ]; + } + + return $attributes; + } + + /** + * Returns default mappings. + * + * @return array + * Array of default mappings. + */ + protected function getDefaultMappings() { + return [ + ['field' => 'label', 'attribute' => 'description', 'mapping_type' => 'simple'], + ['field' => 'field_dept_code', 'attribute' => 'imeccDepartmentCode', 'mapping_type' => 'simple'], + ['field' => 'field_dept_acronym', 'attribute' => 'cn', 'mapping_type' => 'simple'], + ['field' => 'field_dept_type', 'attribute' => 'imeccDepartmentType', 'mapping_type' => 'simple'], + ['field' => 'field_dept_phone', 'attribute' => 'telephoneNumber', 'mapping_type' => 'simple'], + ['field' => 'field_dept_room', 'attribute' => 'roomNumber', 'mapping_type' => 'simple'], + ['field' => 'field_dept_mail', 'attribute' => 'mail', 'mapping_type' => 'simple'], + ]; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + // Validate attribute mappings + $mapping_values = $form_state->getValue('mappings'); + if (!empty($mapping_values)) { + foreach ($mapping_values as $index => $mapping_data) { + // Skip rows marked for removal + if (!empty($mapping_data['remove'])) { + continue; + } + + // Check if there are partially filled fields + $has_field = !empty($mapping_data['field']); + $has_attribute = !empty($mapping_data['attribute']); + + if ($has_field && !$has_attribute) { + $form_state->setErrorByName("mappings][$index][attribute", + $this->t('LDAP attribute is required when field is selected.')); + } + elseif (!$has_field && $has_attribute) { + $form_state->setErrorByName("mappings][$index][field", + $this->t('Vocabulary field is required when attribute is selected.')); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + parent::submitForm($form, $form_state); + + $config = $this->config('ldap_departments_sync.settings'); + + // Save basic configurations + $config->set('ldap_server_id', $form_state->getValue('ldap_server_id')) + ->set('ldap_query_id', $form_state->getValue('ldap_query_id')) + ->set('enable_hierarchy', $form_state->getValue('enable_hierarchy')) + ->set('parent_attribute', $form_state->getValue('parent_attribute')) + ->set('child_attribute', $form_state->getValue('child_attribute')) + ->set('group_type_id', $form_state->getValue('group_type_id')) + ->set('sync_users', $form_state->getValue('sync_users')); + + // Process attribute mappings + $mappings = []; + + // Process attribute mappings + $mapping_values = $form_state->getValue('mappings'); + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + // Skip only mappings marked for removal + if (!empty($mapping_data['remove'])) { + continue; + } + + // Only skip if BOTH field AND attribute are empty (completely empty row) + if (empty($mapping_data['field']) && empty($mapping_data['attribute'])) { + continue; + } + + $mapping = [ + 'field' => $mapping_data['field'], + 'attribute' => $mapping_data['attribute'], + 'mapping_type' => $mapping_data['mapping_type'] ?? 'simple', + ]; + + $mappings[] = $mapping; + } + } + + $config->set('attribute_mappings', $mappings); + + // Process role mappings + $role_mappings = []; + $role_mapping_values = $form_state->getValue('role_mappings'); + + if (!empty($role_mapping_values)) { + foreach ($role_mapping_values as $role_mapping_data) { + // Skip mappings marked for removal + if (!empty($role_mapping_data['remove'])) { + continue; + } + + // Skip completely empty rows + if (empty($role_mapping_data['group_role']) && empty($role_mapping_data['source_field'])) { + continue; + } + + $source = $role_mapping_data['source']; + + // Process values based on source type + if ($source === 'group_field_match') { + // For group_field_match, 'values' field contains the group field name + $role_mapping = [ + 'group_role' => $role_mapping_data['group_role'], + 'source' => $source, + 'source_field' => $role_mapping_data['source_field'], + 'group_field' => $role_mapping_data['values'], // group field is stored in values field + 'values' => [], // No fixed values for group field match + ]; + } else { + // For fixed value match (ldap_attribute or user_field) + $values = $role_mapping_data['values']; + if (is_string($values)) { + $values = array_map('trim', explode(',', $values)); + $values = array_filter($values); // Remove empty values + } + + $role_mapping = [ + 'group_role' => $role_mapping_data['group_role'], + 'source' => $source, + 'source_field' => $role_mapping_data['source_field'], + 'group_field' => '', // No group field for fixed value match + 'values' => $values, + ]; + } + + $role_mappings[] = $role_mapping; + } + } + + $config->set('role_mapping_enabled', $form_state->getValue('role_mapping_enabled')) + ->set('role_mappings', $role_mappings) + ->save(); + + $this->messenger()->addStatus($this->t('Configuration saved successfully.')); + } + + /** + * Submit handler to test LDAP connection. + */ + public function testConnection(array &$form, FormStateInterface $form_state) { + $server_id = $form_state->getValue('ldap_server_id'); + + if (empty($server_id)) { + $this->messenger->addError($this->t('Selecione um servidor LDAP para testar.')); + return; + } + + try { + // Teste simples de conexão + $server_storage = $this->entityTypeManager->getStorage('ldap_server'); + $server = $server_storage->load($server_id); + + if ($server && $server->get('status')) { + $this->messenger->addStatus($this->t('Servidor LDAP "@server" está configurado e ativo.', [ + '@server' => $server->label(), + ])); + } + else { + $this->messenger->addError($this->t('Servidor LDAP não encontrado ou inativo.')); + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Erro ao testar conexão: @message', ['@message' => $e->getMessage()])); + } + } + + /** + * Submit handler para sincronizar departamentos. + */ + public function syncDepartments(array &$form, FormStateInterface $form_state) { + if ($form_state->getErrors()) { + $this->messenger->addError($this->t('Corrija os erros no formulário antes de executar a sincronização.')); + return; + } + + try { + $this->ldapDepartmentsSync->syncDepartments(); + $this->messenger->addStatus($this->t('Sincronização executada com sucesso. Verifique os logs para detalhes.')); + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Erro durante sincronização: @message', ['@message' => $e->getMessage()])); + } + } + + /** + * Callback AJAX para atualizar lista de queries quando servidor muda. + */ + public function updateLdapQueries(array &$form, FormStateInterface $form_state) { + return $form['ldap_query']; + } + + /** + * Submit handler para adicionar um novo mapeamento. + */ + public function addMapping(array &$form, FormStateInterface $form_state) { + // Obter valores atuais do formulário ou da configuração + $current_mappings = $form_state->getValue('mappings'); + + // Se não há valores no form_state, carregar da configuração + if (empty($current_mappings)) { + $config = $this->config('ldap_departments_sync.settings'); + $current_mappings = $config->get('attribute_mappings') ?: $this->getDefaultMappings(); + } + + // Converter para formato esperado se necessário + $processed_mappings = []; + foreach ($current_mappings as $mapping) { + $processed_mappings[] = [ + 'field' => $mapping['field'] ?? '', + 'attribute' => $mapping['attribute'] ?? '', + 'mapping_type' => $mapping['mapping_type'] ?? 'simple', + 'remove' => FALSE, + ]; + } + + // Adicionar nova linha vazia + $processed_mappings[] = [ + 'field' => '', + 'attribute' => '', + 'mapping_type' => 'simple', + 'remove' => FALSE, + ]; + + // Atualizar form_state + $form_state->setValue('mappings', $processed_mappings); + $form_state->set('mapping_count', count($processed_mappings)); + $form_state->setRebuild(); + } + + /** + * Submit handler para remover mapeamentos selecionados. + */ + public function removeSelectedMappings(array &$form, FormStateInterface $form_state) { + $mapping_values = $form_state->getValue('mappings'); + $new_mappings = []; + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + // Manter apenas mapeamentos que não estão marcados para remoção + if (empty($mapping_data['remove'])) { + $new_mappings[] = $mapping_data; + } + } + } + + // Atualizar form_state com os mapeamentos restantes + $form_state->setValue('mappings', $new_mappings); + $form_state->set('mapping_count', count($new_mappings)); + $form_state->setRebuild(); + } + + /** + * Callback AJAX para atualizar mapeamentos de atributos. + */ + public function updateAttributeMappings(array &$form, FormStateInterface $form_state) { + return $form['attribute_mapping']; + } + + /** + * Submit handler to add a new role mapping. + */ + public function addRoleMapping(array &$form, FormStateInterface $form_state) { + $current_mappings = $form_state->getValue('role_mappings'); + + if (empty($current_mappings)) { + $config = $this->config('ldap_departments_sync.settings'); + $current_mappings = $config->get('role_mappings') ?: []; + } + + $processed_mappings = []; + foreach ($current_mappings as $mapping) { + $processed_mappings[] = [ + 'group_role' => $mapping['group_role'] ?? '', + 'source' => $mapping['source'] ?? 'ldap_attribute', + 'source_field' => $mapping['source_field'] ?? '', + 'values' => $mapping['values'] ?? '', + 'remove' => FALSE, + ]; + } + + // Add new empty row + $processed_mappings[] = [ + 'group_role' => '', + 'source' => 'ldap_attribute', + 'source_field' => '', + 'values' => '', + 'remove' => FALSE, + ]; + + $form_state->setValue('role_mappings', $processed_mappings); + $form_state->set('role_mapping_count', count($processed_mappings)); + $form_state->setRebuild(); + } + + /** + * Submit handler to remove selected role mappings. + */ + public function removeSelectedRoleMappings(array &$form, FormStateInterface $form_state) { + $mapping_values = $form_state->getValue('role_mappings'); + $new_mappings = []; + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + if (empty($mapping_data['remove'])) { + $new_mappings[] = $mapping_data; + } + } + } + + $form_state->setValue('role_mappings', $new_mappings); + $form_state->set('role_mapping_count', count($new_mappings)); + $form_state->setRebuild(); + } + + /** + * AJAX callback to update role mapping fields. + */ + public function updateRoleMappingFields(array &$form, FormStateInterface $form_state) { + return $form['role_mapping']['role_mapping_fields']; + } + + + /** + * Gets available roles for the selected group type. + * + * @param string $group_type_id + * Group type ID. + * + * @return array + * Array of roles [role_id => role_label]. + */ + protected function getGroupRoles($group_type_id) { + $roles = []; + + if (empty($group_type_id)) { + return $roles; + } + + try { + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if ($group_type) { + $group_role_storage = $this->entityTypeManager->getStorage('group_role'); + $group_roles = $group_role_storage->loadByProperties([ + 'group_type' => $group_type_id, + ]); + + foreach ($group_roles as $role) { + $role_id = $role->id(); + + // Skip internal-only roles (anonymous and outsider) + if (strpos($role_id, '-anonymous') !== FALSE || strpos($role_id, '-outsider') !== FALSE) { + continue; + } + + // Include scope in the label to distinguish between roles with same label + // The role ID format is typically: {group_type}-{role_name}_{scope} + // Example: departments-admin_in or departments-admin_out + $scope_label = ''; + + if (strpos($role_id, '_in') !== FALSE) { + $scope_label = ' (' . $this->t('Internal') . ')'; + } elseif (strpos($role_id, '_out') !== FALSE) { + $scope_label = ' (' . $this->t('Outsider') . ')'; + } + + $roles[$role_id] = $role->label() . $scope_label; + } + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group roles: @message', ['@message' => $e->getMessage()])); + } + + return $roles; + } + + /** + * Gets available user fields for mapping. + * + * @return array + * Array of user fields [field_name => label]. + */ + protected function getUserFields() { + $fields = []; + + try { + $field_manager = \Drupal::service('entity_field.manager'); + $field_definitions = $field_manager->getFieldDefinitions('user', 'user'); + + foreach ($field_definitions as $field_name => $field_definition) { + // Skip system fields + if (in_array($field_name, ['uid', 'uuid', 'langcode', 'name', 'pass', 'mail', 'timezone', 'status', 'created', 'changed', 'access', 'login', 'init', 'roles', 'default_langcode'])) { + continue; + } + + $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading user fields: @message', ['@message' => $e->getMessage()])); + } + + return $fields; + } + + /** + * Callback AJAX para atualizar campos de hierarquia quando switch é alterado. + */ + public function updateHierarchyFields(array &$form, FormStateInterface $form_state) { + return $form['hierarchy']['hierarchy_fields']; + } + + /** + * Obtém lista de queries LDAP para um servidor específico. + * + * @param string $server_id + * ID do servidor LDAP. + * + * @return array + * Array de queries LDAP [id => label]. + */ + protected function getLdapQueriesForServer($server_id) { + $queries = []; + + if (empty($server_id)) { + return $queries; + } + + // Verifica se o entity type ldap_query_entity existe + if (!$this->entityTypeManager->hasDefinition('ldap_query_entity')) { + $this->messenger->addWarning($this->t('Módulo ldap_query não está disponível. As queries LDAP não podem ser carregadas.')); + return $queries; + } + + try { + $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity'); + $query_entities = $query_storage->loadByProperties([ + 'status' => TRUE, + 'server_id' => $server_id, + ]); + + foreach ($query_entities as $query) { + $queries[$query->id()] = $query->label() . ' (' . $query->get('base_dn') . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Erro ao carregar queries LDAP: @message', ['@message' => $e->getMessage()])); + } + + return $queries; + } + + /** + * Gets list of available group types. + * + * @return array + * Array of group types [id => label]. + */ + protected function getGroupTypes() { + $group_types = []; + + try { + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type_entities = $group_type_storage->loadMultiple(); + + foreach ($group_type_entities as $group_type) { + $group_types[$group_type->id()] = $group_type->label() . ' (' . $group_type->id() . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group types: @message', ['@message' => $e->getMessage()])); + } + + return $group_types; + } + + /** + * Gets fields from the selected group type. + * + * @param string $group_type_id + * Group type ID. + * + * @return array + * Array of fields [field_name => label]. + */ + protected function getGroupTypeFields($group_type_id) { + $fields = [ + 'label' => $this->t('Label (group title)'), + ]; + + if (empty($group_type_id)) { + return $fields; + } + + try { + // Check if group type exists + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if (!$group_type) { + $this->messenger->addWarning($this->t('Group type "@type" not found. Please create it first or select a different group type.', [ + '@type' => $group_type_id, + ])); + return $fields; + } + + $field_manager = \Drupal::service('entity_field.manager'); + $field_definitions = $field_manager->getFieldDefinitions('group', $group_type_id); + + foreach ($field_definitions as $field_name => $field_definition) { + // Skip system fields + if (in_array($field_name, ['id', 'uuid', 'type', 'langcode', 'label', 'uid', 'created', 'changed', 'default_langcode'])) { + continue; + } + + $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group type fields: @message', ['@message' => $e->getMessage()])); + } + + return $fields; + } + + /** + * Callback AJAX to update group type actions when group type changes. + */ + public function updateGroupTypeActions(array &$form, FormStateInterface $form_state) { + return $form['group_type']; + } + + /** + * Submit handler to install default configuration. + */ + public function installDefaultConfiguration(array &$form, FormStateInterface $form_state) { + $module_path = \Drupal::service('extension.list.module')->getPath('ldap_departments_sync'); + $config_path = $module_path . '/config/optional'; + + if (!is_dir($config_path)) { + $this->messenger->addError($this->t('Configuration directory not found: @path', ['@path' => $config_path])); + return; + } + + try { + $config_factory = \Drupal::configFactory(); + $yaml_parser = \Drupal::service('serialization.yaml'); + $files_imported = 0; + + // Ordem de importação + $import_order = [ + // Field storages primeiro + 'field.storage.*.yml', + // Tipo de grupo + 'group.type.*.yml', + // Roles do grupo + 'group.role.*.yml', + // Field instances + 'field.field.*.yml', + // Displays + 'core.entity_*.yml', + ]; + + foreach ($import_order as $pattern) { + $files = glob($config_path . '/' . $pattern); + foreach ($files as $file) { + $config_name = basename($file, '.yml'); + + // Verificar se já existe + $existing_config = $config_factory->get($config_name); + if (!$existing_config->isNew()) { + $this->messenger->addWarning($this->t('Configuration @name already exists, skipping.', ['@name' => $config_name])); + continue; + } + + // Importar + $yaml_content = file_get_contents($file); + $data = $yaml_parser->decode($yaml_content); + + $config_factory->getEditable($config_name) + ->setData($data) + ->save(); + + $files_imported++; + } + } + + if ($files_imported > 0) { + // Limpar caches necessários + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + \Drupal::service('plugin.manager.field.widget')->clearCachedDefinitions(); + \Drupal::service('plugin.manager.field.formatter')->clearCachedDefinitions(); + + // Limpar cache de configuração e rotas + \Drupal::service('config.factory')->clearStaticCache(); + \Drupal::service('router.builder')->rebuild(); + + $this->messenger->addStatus($this->t('Successfully installed @count configuration items. Group type "departments" and all required fields have been created. Please reload the page to see the changes.', [ + '@count' => $files_imported, + ])); + } else { + $this->messenger->addWarning($this->t('All configurations already exist. Nothing was imported.')); + } + + } catch (\Exception $e) { + $this->messenger->addError($this->t('Error installing default configuration: @message', ['@message' => $e->getMessage()])); + \Drupal::logger('ldap_departments_sync')->error('Error installing configuration: @message', ['@message' => $e->getMessage()]); + } + + // Redirecionar usando a URL atual para forçar reload completo + $current_path = \Drupal::service('path.current')->getPath(); + $form_state->setRedirectUrl(\Drupal\Core\Url::fromUserInput($current_path)); + } + +} \ No newline at end of file diff --git a/ldap_departments_sync/src/LdapDepartmentsSync.php b/ldap_departments_sync/src/LdapDepartmentsSync.php new file mode 100644 index 0000000..40a7e28 --- /dev/null +++ b/ldap_departments_sync/src/LdapDepartmentsSync.php @@ -0,0 +1,1531 @@ +entityTypeManager = $entity_type_manager; + $this->configFactory = $config_factory; + $this->logger = $logger_factory->get('ldap_departments_sync'); + $this->ldapBridge = $ldap_bridge; + + // Check if entity type ldap_query_entity exists + if ($entity_type_manager->hasDefinition('ldap_query_entity')) { + $this->ldapQueryStorage = $entity_type_manager->getStorage('ldap_query_entity'); + } + else { + $this->logger->warning('Entity type ldap_query_entity not found. Check if ldap_query module is properly installed.'); + $this->ldapQueryStorage = NULL; + } + } + + /** + * Synchronizes departments from LDAP to groups. + */ + public function syncDepartments() { + $this->logger->info('Starting departments synchronization using configured LDAP query.'); + + // Verifica configuração antes de iniciar + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $bundle_id = $this->getBundleId(); + + $this->logger->info('Configuração atual - Server: @server, Query: @query, Group Type: @bundle', [ + '@server' => $config->get('ldap_server_id') ?: 'não configurado', + '@query' => $config->get('ldap_query_id') ?: 'não configurado', + '@bundle' => $bundle_id, + ]); + + try { + // Conecta ao LDAP e obtém departments usando query configurada + $departments = $this->fetchDepartmentsUsingQuery(); + } + catch (\Exception $e) { + $this->logger->error('Erro durante fetchDepartmentsUsingQuery: @message', ['@message' => $e->getMessage()]); + throw $e; + } + + if (empty($departments)) { + $this->logger->warning('Nenhum department encontrado no LDAP.'); + return; + } + + $this->logger->info('Iniciando sincronização de @count departments', ['@count' => count($departments)]); + + // Obtém todos as entidades existentes + $entity_storage = $this->getEntityStorage(); + $bundle_field = $this->getBundleField(); + $existing_entities = $entity_storage->loadByProperties([$bundle_field => $bundle_id]); + + // Cria um mapa de códigos existentes + $existing_codes = []; + foreach ($existing_entities as $entity) { + if ($entity->hasField('field_dept_code') && !$entity->get('field_dept_code')->isEmpty()) { + $code = $entity->get('field_dept_code')->value; + $existing_codes[$code] = $entity; + } + } + + $created = 0; + $updated = 0; + + // Processa cada department do LDAP + foreach ($departments as $dept_data) { + $code = $dept_data['code']; + $name = $dept_data['name']; + $acronym = $dept_data['acronym']; + $type = $dept_data['type']; + $phone = $dept_data['phone']; + $room = $dept_data['room']; + $mail = $dept_data['mail']; + + // Campos extras (incluindo referências de usuário) + $extra_fields = []; + foreach ($dept_data as $field => $value) { + if (!in_array($field, ['code', 'name', 'acronym', 'type', 'phone', 'room', 'mail'])) { + $extra_fields[$field] = $value; + } + } + + $this->logger->info('Processando department - Código: @code, Nome: @name, Sigla: @acronym', [ + '@code' => $code, + '@name' => $name, + '@acronym' => $acronym, + ]); + + if (isset($existing_codes[$code])) { + // Atualiza entidade existente + $this->logger->info('Atualizando entidade existente com código: @code', ['@code' => $code]); + $entity = $existing_codes[$code]; + $name_field = $this->getNameField(); + + // Set name/label field + if ($name_field === 'label') { + $entity->set('label', $name); + } + else { + $entity->setName($name); + } + + if ($entity->hasField('field_dept_acronym')) { + $entity->set('field_dept_acronym', $acronym); + } + if ($entity->hasField('field_dept_type')) { + $entity->set('field_dept_type', $type); + } + if ($entity->hasField('field_dept_phone')) { + $entity->set('field_dept_phone', $phone); + } + if ($entity->hasField('field_dept_room')) { + $entity->set('field_dept_room', $room); + } + if ($entity->hasField('field_dept_mail')) { + $entity->set('field_dept_mail', $mail); + } + + // Atualizar campos extras + foreach ($extra_fields as $field => $value) { + if ($entity->hasField($field)) { + $entity->set($field, $value); + $this->logger->debug('Campo @field atualizado com valor @value', [ + '@field' => $field, + '@value' => $value ?? '', + ]); + } else { + $this->logger->warning('Campo @field não existe na entidade', [ + '@field' => $field, + ]); + } + } + + try { + $entity->save(); + $updated++; + $this->logger->info('Entidade atualizada com sucesso: @code', ['@code' => $code]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao atualizar entidade @code: @error', [ + '@code' => $code, + '@error' => $e->getMessage(), + ]); + } + } + else { + // Cria nova entidade + $this->logger->info('Criando nova entidade com código: @code', ['@code' => $code]); + try { + $bundle_field = $this->getBundleField(); + $name_field = $this->getNameField(); + + $entity_data = [ + $bundle_field => $bundle_id, + $name_field => $name, + 'field_dept_code' => $code, + 'field_dept_acronym' => $acronym, + 'field_dept_type' => $type, + 'field_dept_phone' => $phone, + 'field_dept_room' => $room, + 'field_dept_mail' => $mail, + 'uid' => 1, // Admin user as owner + ]; + + // Adicionar campos extras + foreach ($extra_fields as $field => $value) { + $entity_data[$field] = $value; + $this->logger->debug('Campo @field configurado com valor @value', [ + '@field' => $field, + '@value' => $value ?? '', + ]); + } + + $entity = $entity_storage->create($entity_data); + $entity->save(); + $created++; + $this->logger->info('Entidade criada com sucesso: @code (ID: @id)', [ + '@code' => $code, + '@id' => $entity->id(), + ]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao criar entidade @code: @error', [ + '@code' => $code, + '@error' => $e->getMessage(), + ]); + } + } + } + + if ($created > 0 || $updated > 0) { + $this->logger->info('Sincronização concluída com sucesso. Criados: @created, Atualizados: @updated', [ + '@created' => $created, + '@updated' => $updated, + ]); + } + else { + $this->logger->info('Nenhum departamento criado ou atualizado.'); + } + + // Aplica hierarquização se habilitada + $this->applyHierarchy(); + + // Atualiza campo field_user_department dos usuários locais + $this->syncUserDepartments(); + + // Sincroniza membros dos grupos + $this->syncGroupMembers(); + } + + + /** + * Busca entidade de departamento pelo código. + * + * @param string $dept_code + * Código do departamento. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * Entidade (termo de taxonomia ou grupo) ou NULL se não encontrado. + */ + public function getDepartmentByCode($dept_code) { + if (empty($dept_code)) { + return NULL; + } + + $bundle_id = $this->getBundleId(); + $bundle_field = $this->getBundleField(); + $entity_storage = $this->getEntityStorage(); + + $entities = $entity_storage->loadByProperties([ + $bundle_field => $bundle_id, + 'field_dept_code' => $dept_code, + ]); + + return !empty($entities) ? reset($entities) : NULL; + } + + /** + * Busca departments usando a query LDAP configurada. + * + * @return array + * Array de departments com código e descrição. + */ + protected function fetchDepartmentsUsingQuery() { + $departments = []; + + $this->logger->info('Iniciando fetchDepartmentsUsingQuery...'); + + // Verifica se o storage de queries está disponível + if (!$this->ldapQueryStorage) { + $this->logger->error('Storage de queries LDAP não está disponível. Verifique se o módulo ldap_query está instalado.'); + return $departments; + } + + $this->logger->info('Storage de queries LDAP está disponível.'); + + try { + // Obtém ID da query configurada + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $query_id = $config->get('ldap_query_id') ?: 'department_sync'; + + $this->logger->info('Tentando carregar query LDAP: @query_id', ['@query_id' => $query_id]); + + // Carrega a query LDAP configurada + $query_entity = $this->ldapQueryStorage->load($query_id); + + if (!$query_entity) { + $this->logger->error('Query LDAP "@query_id" não encontrada. Configure a query via /admin/config/local-modules/ldap-departments-sync', [ + '@query_id' => $query_id, + ]); + return $departments; + } + + $this->logger->info('Query LDAP carregada com sucesso: @label', ['@label' => $query_entity->label()]); + + if (!$query_entity->get('status')) { + $this->logger->error('Query LDAP "@query_id" está desabilitada.', [ + '@query_id' => $query_id, + ]); + return $departments; + } + + $this->logger->info('Usando query LDAP configurada: @query_id (@label)', [ + '@query_id' => $query_id, + '@label' => $query_entity->label(), + ]); + + $this->logger->info('Executando query LDAP...'); + + // Verifica métodos disponíveis na query entity + $methods = get_class_methods($query_entity); + $this->logger->info('Métodos disponíveis na query: @methods', ['@methods' => implode(', ', $methods)]); + + // Usa os parâmetros da query com LdapBridge + try { + // Obtém parâmetros da query + $server_id = $query_entity->getServerId(); + $base_dns = $query_entity->getProcessedBaseDns(); + $filter = $query_entity->getFilter(); + $attributes = $query_entity->getProcessedAttributes(); + + $this->logger->info('Parâmetros da query - Server: @server, Base DN: @base_dn, Filter: @filter, Attributes: @attrs', [ + '@server' => $server_id, + '@base_dn' => !empty($base_dns) ? implode(', ', $base_dns) : 'vazio', + '@filter' => $filter, + '@attrs' => !empty($attributes) ? implode(', ', $attributes) : 'vazio', + ]); + + // Configura o LdapBridge com o servidor da query + $this->ldapBridge->setServerById($server_id); + + // Conecta ao servidor + if (!$this->ldapBridge->bind()) { + throw new \Exception('Falha ao conectar ao servidor LDAP'); + } + + $this->logger->info('Conectado ao servidor LDAP com sucesso'); + + // Executa busca para cada Base DN + $all_results = []; + foreach ($base_dns as $base_dn) { + $this->logger->info('Executando busca em Base DN: @base_dn', ['@base_dn' => $base_dn]); + + try { + $ldap = $this->ldapBridge->get(); + $this->logger->info('Obtida instância LDAP do bridge'); + + // Prepara atributos (pode ser array ou string) + $attr_options = []; + if (!empty($attributes)) { + $attr_options = ['filter' => $attributes]; + } + + $this->logger->info('Criando query Symfony LDAP...'); + $query = $ldap->query($base_dn, $filter, $attr_options); + + $this->logger->info('Executando query Symfony LDAP...'); + $base_results = $query->execute(); + + $this->logger->info('Query executada. Tipo de resultado: @type', ['@type' => gettype($base_results)]); + + // Converte para array se necessário + if ($base_results instanceof \Traversable) { + $base_results = iterator_to_array($base_results); + } + + $this->logger->info('Resultados encontrados: @count', ['@count' => count($base_results)]); + + if (!empty($base_results) && is_array($base_results)) { + $all_results = array_merge($all_results, $base_results); + } + } + catch (\Exception $e) { + $this->logger->error('Erro na busca LDAP para Base DN @base_dn: @message', [ + '@base_dn' => $base_dn, + '@message' => $e->getMessage(), + ]); + // Continua para próximo Base DN se houver erro + } + } + + $results = $all_results; + $this->logger->info('Query LDAP executada com sucesso. Total de resultados: @count', ['@count' => count($results)]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]); + $this->logger->error('Stack trace: @trace', ['@trace' => $e->getTraceAsString()]); + throw $e; + } + + $this->logger->info('Verificando resultados da query...'); + + if (empty($results)) { + $this->logger->warning('Query LDAP não retornou resultados.'); + return $departments; + } + + $this->logger->info('Query LDAP retornou @count resultados', ['@count' => count($results)]); + + // Processa os resultados da query usando mapeamentos dinâmicos + $departments = $this->processLdapResults($results); + + $this->logger->info('Processados @count departments via query LDAP', ['@count' => count($departments)]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]); + } + + return $departments; + } + + /** + * Sincroniza departamentos dos usuários locais do Drupal. + * + * Este método atualiza o campo field_user_department dos usuários locais + * (criados pelo ldap_user) fazendo match entre field_user_dept_code e + * field_dept_code do departamento. + */ + public function syncUserDepartments() { + // Obtém configurações + $config = $this->configFactory->get('ldap_departments_sync.settings'); + + // Verifica se a sincronização de usuários está habilitada + if (!$config->get('sync_users')) { + $this->logger->info('User synchronization is disabled.'); + return; + } + + $this->logger->info('Starting user department field synchronization.'); + + // Carrega todos os usuários ativos que têm código de departamento + $user_storage = $this->entityTypeManager->getStorage('user'); + $query = $user_storage->getQuery() + ->condition('status', 1) + ->condition('uid', 0, '>') // Exclui usuário anônimo + ->exists('field_user_dept_code') + ->accessCheck(FALSE); + + $uids = $query->execute(); + + if (empty($uids)) { + $this->logger->warning('No users with field_user_dept_code found.'); + return; + } + + $users = $user_storage->loadMultiple($uids); + $this->logger->info('Processing @count users for department field update', ['@count' => count($users)]); + + // Carrega todos os departamentos uma vez + $dept_storage = $this->getEntityStorage(); + $bundle_field = $this->getBundleField(); + $bundle_id = $this->getBundleId(); + $departments = $dept_storage->loadByProperties([$bundle_field => $bundle_id]); + + // Cria um mapa de dept_code => department para busca rápida + $depts_by_code = []; + foreach ($departments as $dept) { + if ($dept->hasField('field_dept_code') && !$dept->get('field_dept_code')->isEmpty()) { + $code = $dept->get('field_dept_code')->value; + $depts_by_code[$code] = $dept; + } + } + + $this->logger->info('Found @count departments with codes', ['@count' => count($depts_by_code)]); + + $updated = 0; + $skipped = 0; + + // Sinaliza que os saves a seguir fazem parte de um sync LDAP autorizado, + // de modo que hook_user_presave() não bloqueie as alterações. + self::$syncing = TRUE; + try { + foreach ($users as $user) { + $username = $user->getAccountName(); + + // Obtém o código de departamento do usuário + if (!$user->hasField('field_user_dept_code') || $user->get('field_user_dept_code')->isEmpty()) { + $skipped++; + continue; + } + + $user_dept_code = $user->get('field_user_dept_code')->value; + + // Busca o departamento correspondente pelo código + if (!isset($depts_by_code[$user_dept_code])) { + $this->logger->warning('No department found with code "@code" for user @username. field_user_department will not be updated.', [ + '@code' => $user_dept_code, + '@username' => $username, + ]); + $skipped++; + continue; + } + + $department = $depts_by_code[$user_dept_code]; + + // Atualiza campo field_user_department do usuário + if ($user->hasField('field_user_department')) { + $current_dept = $user->get('field_user_department')->target_id; + if ($current_dept != $department->id()) { + $user->set('field_user_department', $department->id()); + $user->save(); + $updated++; + } else { + $skipped++; + } + } else { + $this->logger->warning('User @username does not have field_user_department', [ + '@username' => $username, + ]); + $skipped++; + } + } + } + finally { + self::$syncing = FALSE; + } + + $this->logger->info('User department field synchronization completed. Updated: @updated, Skipped: @skipped', [ + '@updated' => $updated, + '@skipped' => $skipped, + ]); + } + + /** + * Sincroniza membros dos grupos baseado no código de departamento dos usuários. + * + * Este método adiciona usuários do Drupal como membros dos grupos fazendo + * match entre user.field_user_dept_code e group.field_dept_code. + */ + public function syncGroupMembers() { + $this->logger->info('Starting group membership synchronization.'); + + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $role_mapping_enabled = $config->get('role_mapping_enabled') ?? FALSE; + $role_mappings = $config->get('role_mappings') ?? []; + + // Se role mapping não está habilitado, não faz sincronização + if (!$role_mapping_enabled) { + $this->logger->info('Role mapping is disabled. Skipping group membership synchronization.'); + return; + } + + if (empty($role_mappings)) { + $this->logger->warning('Role mapping is enabled but no mappings are configured. Skipping synchronization.'); + return; + } + + // Carrega todos os usuários ativos + $user_storage = $this->entityTypeManager->getStorage('user'); + $query = $user_storage->getQuery() + ->condition('status', 1) + ->condition('uid', 0, '>') // Exclui usuário anônimo + ->accessCheck(FALSE); + + $uids = $query->execute(); + + if (empty($uids)) { + $this->logger->warning('No active users found.'); + return; + } + + $users = $user_storage->loadMultiple($uids); + $this->logger->info('Processing @count users for group membership', ['@count' => count($users)]); + + // Carrega todos os grupos + $group_storage = $this->entityTypeManager->getStorage('group'); + $group_type_id = $this->getBundleId(); + $groups = $group_storage->loadByProperties(['type' => $group_type_id]); + + $this->logger->info('Found @count groups', ['@count' => count($groups)]); + + $added = 0; + $removed = 0; + $role_updated = 0; + $skipped = 0; + $already_member = 0; + $matched = 0; + + // Para cada usuário, verifica em quais grupos ele deve estar + foreach ($users as $user) { + try { + $username = $user->getAccountName(); + + // Determina em quais grupos e com quais roles o usuário deve estar + $expected_memberships = []; // [group_id => role_id] + + foreach ($groups as $group) { + $group_role = $this->determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings); + + // Se determinou um role, o usuário deve estar neste grupo + if ($group_role !== NULL) { + $expected_memberships[$group->id()] = $group_role; + } + } + + if (!empty($expected_memberships)) { + $matched++; + } + + // Processa as memberships esperadas + foreach ($expected_memberships as $group_id => $expected_role) { + $group = $groups[$group_id]; + $membership = $group->getMember($user); + + if ($membership) { + // Usuário já é membro, verifica se precisa atualizar o papel + $current_roles = $membership->getRoles(); + $current_role_ids = array_map(function($role) { + return $role->id(); + }, $current_roles); + + // Se o papel esperado não está entre os papéis atuais, atualiza + if (!in_array($expected_role, $current_role_ids)) { + try { + // Remove papéis antigos (exceto 'member' base e o novo papel) + foreach ($current_role_ids as $role_id) { + if ($role_id !== 'member' && $role_id !== $expected_role) { + $membership->removeRole($role_id); + } + } + // Adiciona novo papel (se não for apenas 'member') + if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) { + $membership->addRole($expected_role); + } + $membership->save(); + $role_updated++; + $this->logger->info('Updated role for user @username in group @group to @role', [ + '@username' => $username, + '@group' => $group->label(), + '@role' => $expected_role, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to update role for user @username in group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + else { + $already_member++; + } + } else { + // Usuário não é membro, adiciona + try { + $values = []; + // Adiciona role se não for o member padrão + if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) { + $values['group_roles'] = [$expected_role]; + } + $group->addMember($user, $values); + $added++; + $this->logger->info('Added user @username to group @group with role @role', [ + '@username' => $username, + '@group' => $group->label(), + '@role' => $expected_role, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to add user @username to group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + $skipped++; + } + } + } + + // Remove usuário de grupos onde ele não deveria estar mais + foreach ($groups as $group) { + if (!isset($expected_memberships[$group->id()])) { + $membership = $group->getMember($user); + if ($membership) { + try { + $group->removeMember($user); + $removed++; + $this->logger->info('Removed user @username from group @group', [ + '@username' => $username, + '@group' => $group->label(), + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to remove user @username from group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + } + } + } catch (\Exception $e) { + $this->logger->error('Error processing user @uid: @error', [ + '@uid' => $user->id(), + '@error' => $e->getMessage(), + ]); + $skipped++; + } + } + + $this->logger->info('Group membership synchronization completed. Matched: @matched, Added: @added, Already member: @already, Removed: @removed, Role updated: @role_updated, Skipped: @skipped', [ + '@matched' => $matched, + '@added' => $added, + '@already' => $already_member, + '@removed' => $removed, + '@role_updated' => $role_updated, + '@skipped' => $skipped, + ]); + } + + /** + * Determina o papel (role) de um usuário em um grupo baseado nos mapeamentos. + * + * @param \Drupal\user\UserInterface $user + * O usuário para verificar. + * @param \Drupal\group\Entity\GroupInterface $group + * O grupo onde o usuário será adicionado. + * @param bool $role_mapping_enabled + * Se o mapeamento de papéis está habilitado. + * @param array $role_mappings + * Array de mapeamentos de papéis. + * + * @return string|null + * O ID do papel a ser atribuído ao usuário, ou NULL se não houver match. + */ + protected function determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings) { + // Se mapeamento de papéis não está habilitado, retorna NULL + if (!$role_mapping_enabled || empty($role_mappings)) { + return NULL; + } + + // Itera pelos mapeamentos na ordem configurada + foreach ($role_mappings as $mapping) { + $group_role = $mapping['group_role'] ?? ''; + $source = $mapping['source'] ?? 'user_field'; + $source_field = $mapping['source_field'] ?? ''; + $values = $mapping['values'] ?? []; + $group_field = $mapping['group_field'] ?? ''; + + if (empty($group_role) || empty($source_field)) { + continue; + } + + $user_value = NULL; + + // Obtém o valor do usuário baseado na fonte + if ($source === 'user_field') { + // Busca em campo do usuário do Drupal + if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty()) { + $field = $user->get($source_field); + // Trata diferentes tipos de campo + $field_type = $field->getFieldDefinition()->getType(); + if (in_array($field_type, ['entity_reference', 'entity_reference_revisions'])) { + // Para referências de entidade, pega o target_id + $user_value = $field->target_id; + } else { + // Para campos simples, pega o value + $user_value = $field->value; + } + } + } elseif ($source === 'ldap_attribute') { + // Para atributos LDAP, precisa buscar do LDAP + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $ldap_user_data = $this->fetchUserLdapAttribute($user, $source_field, $config); + if ($ldap_user_data !== NULL) { + $user_value = $ldap_user_data; + } + } elseif ($source === 'group_field_match') { + // Para group_field_match, compara campo do usuário com campo do grupo + if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty() && + $group->hasField($group_field) && !$group->get($group_field)->isEmpty()) { + + $user_field = $user->get($source_field); + $group_field_obj = $group->get($group_field); + + // Obtém valores + $user_field_type = $user_field->getFieldDefinition()->getType(); + if (in_array($user_field_type, ['entity_reference', 'entity_reference_revisions'])) { + $user_value = $user_field->target_id; + } else { + $user_value = $user_field->value; + } + + $group_field_type = $group_field_obj->getFieldDefinition()->getType(); + if (in_array($group_field_type, ['entity_reference', 'entity_reference_revisions'])) { + $group_value = $group_field_obj->target_id; + } else { + $group_value = $group_field_obj->value; + } + + // Compara valores (case-insensitive) + if ($user_value !== NULL && $group_value !== NULL && + strcasecmp(trim($user_value), trim($group_value)) === 0) { + return $group_role; + } + } + continue; // Não há valores fixos para comparar, então pula para próximo mapping + } + + // Verifica se o valor do usuário corresponde a algum dos valores fixos do mapeamento + if ($user_value !== NULL && !empty($values)) { + foreach ($values as $mapping_value) { + if (strcasecmp(trim($user_value), trim($mapping_value)) === 0) { + return $group_role; + } + } + } + } + + // Se nenhum mapeamento corresponder, retorna NULL (usuário não deve estar neste grupo) + return NULL; + } + + /** + * Busca um atributo LDAP específico para um usuário. + * + * @param \Drupal\user\UserInterface $user + * O usuário. + * @param string $attribute + * O atributo LDAP a buscar. + * @param \Drupal\Core\Config\ImmutableConfig $config + * Configuração do módulo. + * + * @return mixed|null + * O valor do atributo ou NULL se não encontrado. + */ + protected function fetchUserLdapAttribute($user, $attribute, $config) { + try { + // Obtém configurações LDAP + $host = $config->get('ldap_host'); + $port = $config->get('ldap_port'); + $base_dn = $config->get('users_base_dn') ?? $config->get('ldap_base_dn'); + $bind_dn = $config->get('ldap_bind_dn'); + $bind_password = $config->get('ldap_bind_password'); + $users_filter = $config->get('users_filter') ?? '(objectClass=person)'; + + // Valida configurações mínimas + if (empty($host) || empty($base_dn)) { + return NULL; + } + + // Conecta ao servidor LDAP + $ldap_connection = ldap_connect($host, $port); + if (!$ldap_connection) { + return NULL; + } + + ldap_set_option($ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($ldap_connection, LDAP_OPT_REFERRALS, 0); + + // Faz bind + if (!empty($bind_dn) && !empty($bind_password)) { + $bind = @ldap_bind($ldap_connection, $bind_dn, $bind_password); + } else { + $bind = @ldap_bind($ldap_connection); + } + + if (!$bind) { + ldap_close($ldap_connection); + return NULL; + } + + // Busca o usuário + $username = $user->getAccountName(); + $filter = "(&{$users_filter}(uid={$username}))"; + $search = ldap_search($ldap_connection, $base_dn, $filter, [$attribute]); + + if (!$search) { + ldap_close($ldap_connection); + return NULL; + } + + $entries = ldap_get_entries($ldap_connection, $search); + ldap_close($ldap_connection); + + // Retorna o valor do atributo se encontrado + if (!empty($entries) && $entries['count'] > 0 && isset($entries[0][$attribute])) { + return $entries[0][$attribute][0] ?? NULL; + } + + return NULL; + } catch (\Exception $e) { + $this->logger->error('Error fetching LDAP attribute @attribute for user @username: @error', [ + '@attribute' => $attribute, + '@username' => $user->getAccountName(), + '@error' => $e->getMessage(), + ]); + return NULL; + } + } + + /** + * Busca usuários do servidor LDAP. + * + * @param \Drupal\Core\Config\ImmutableConfig $config + * Configuração do módulo. + * + * @return array + * Array de usuários com username e código do departamento. + */ + protected function fetchUsersFromLdap($config) { + $users = []; + + // Obtém configurações LDAP + $ldap_host = $config->get('ldap_host') ?: 'ldap://localhost'; + $ldap_port = $config->get('ldap_port') ?: 389; + $users_base_dn = $config->get('users_base_dn') ?: 'ou=People,dc=example,dc=com'; + $ldap_bind_dn = $config->get('ldap_bind_dn') ?: ''; + $ldap_bind_password = $config->get('ldap_bind_password') ?: ''; + $users_filter = $config->get('users_filter') ?: '(objectClass=person)'; + $users_department_attribute = $config->get('users_department_attribute') ?: 'departmentNumber'; + + // Valida configurações essenciais + if (empty($ldap_host) || empty($users_base_dn)) { + $this->logger->error('Configurações LDAP para usuários incompletas. Host: @host, Base DN: @base_dn', [ + '@host' => $ldap_host, + '@base_dn' => $users_base_dn, + ]); + return $users; + } + + // Verifica se a extensão LDAP está disponível + if (!function_exists('ldap_connect')) { + $this->logger->error('Extensão PHP LDAP não está instalada.'); + return $users; + } + + // Conecta ao servidor LDAP (reutiliza lógica existente) + $ldap_url = $ldap_host; + if (!parse_url($ldap_host, PHP_URL_PORT) && $ldap_port != 389) { + $scheme = parse_url($ldap_host, PHP_URL_SCHEME) ?: 'ldap'; + $host = parse_url($ldap_host, PHP_URL_HOST) ?: str_replace(['ldap://', 'ldaps://'], '', $ldap_host); + $ldap_url = $scheme . '://' . $host . ':' . $ldap_port; + } + + $this->logger->info('Conectando ao LDAP para usuários: @url', ['@url' => $ldap_url]); + + $ldap_conn = ldap_connect($ldap_url); + + if (!$ldap_conn) { + $this->logger->error('Não foi possível criar conexão LDAP para usuários: @url', ['@url' => $ldap_url]); + return $users; + } + + // Define opções LDAP + ldap_set_option($ldap_conn, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($ldap_conn, LDAP_OPT_REFERRALS, 0); + ldap_set_option($ldap_conn, LDAP_OPT_NETWORK_TIMEOUT, 10); + + // Autentica no LDAP + if ($ldap_bind_dn && $ldap_bind_password) { + $ldap_bind = @ldap_bind($ldap_conn, $ldap_bind_dn, $ldap_bind_password); + } + else { + $ldap_bind = @ldap_bind($ldap_conn); + } + + if (!$ldap_bind) { + $error = ldap_error($ldap_conn); + $errno = ldap_errno($ldap_conn); + $this->logger->error('Falha na autenticação LDAP para usuários: @error (Código: @errno)', [ + '@error' => $error, + '@errno' => $errno, + ]); + ldap_close($ldap_conn); + return $users; + } + + // Executa busca LDAP + $this->logger->info('Executando busca LDAP para usuários - Base DN: @base_dn, Filtro: @filter', [ + '@base_dn' => $users_base_dn, + '@filter' => $users_filter, + ]); + + // Define atributos específicos para retornar + $attributes = [ + 'uid', + 'cn', + $users_department_attribute, + 'dn' + ]; + + $search_result = @ldap_search($ldap_conn, $users_base_dn, $users_filter, $attributes); + + if (!$search_result) { + $error = ldap_error($ldap_conn); + $errno = ldap_errno($ldap_conn); + $this->logger->error('Erro na busca LDAP para usuários: @error (Código: @errno)', [ + '@error' => $error, + '@errno' => $errno, + ]); + ldap_close($ldap_conn); + return $users; + } + + // Obtém entradas + $entries = ldap_get_entries($ldap_conn, $search_result); + $this->logger->info('Encontrados @count usuários no LDAP', ['@count' => $entries['count']]); + + // Processa resultados + for ($i = 0; $i < $entries['count']; $i++) { + $entry = $entries[$i]; + + // Busca case-insensitive dos atributos + $username = ''; + $department_code = ''; + + foreach ($entry as $attr_name => $attr_value) { + if (strcasecmp($attr_name, 'uid') === 0 && isset($attr_value[0])) { + $username = trim($attr_value[0]); + } + elseif (strcasecmp($attr_name, $users_department_attribute) === 0 && isset($attr_value[0])) { + $department_code = trim($attr_value[0]); + } + } + + if (!empty($username) && !empty($department_code)) { + $users[] = [ + 'username' => $username, + 'department_code' => $department_code, + ]; + $this->logger->debug('Usuário encontrado - Username: @username, Dept Code: @dept', [ + '@username' => $username, + '@dept' => $department_code, + ]); + } + } + + // Fecha conexão + ldap_close($ldap_conn); + + $this->logger->info('Processados @count usuários do LDAP', ['@count' => count($users)]); + return $users; + } + + /** + * Processes LDAP results using configured attribute mappings. + * + * @param array $results + * Array of LDAP results. + * + * @return array + * Array of processed departments. + */ + protected function processLdapResults(array $results) { + $departments = []; + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $attribute_mappings = $config->get('attribute_mappings') ?: []; + + // Get the code mapping to use as identifier + $code_mapping = null; + foreach ($attribute_mappings as $mapping) { + if ($mapping['field'] === 'field_dept_code') { + $code_mapping = $mapping; + break; + } + } + + if (!$code_mapping) { + $this->logger->error('No field_dept_code mapping found. Cannot process results.'); + return $departments; + } + + $this->logger->debug('Processing @count LDAP results with @mappings mappings', [ + '@count' => count($results), + '@mappings' => count($attribute_mappings), + ]); + + foreach ($results as $entry) { + $dept_data = []; + + // Process each attribute mapping + foreach ($attribute_mappings as $mapping) { + $field = $mapping['field']; + $attribute = $mapping['attribute']; + $mapping_type = $mapping['mapping_type'] ?? 'simple'; + + // Get value from LDAP entry (Entry object) + $value = null; + if ($entry->hasAttribute($attribute)) { + $attr_values = $entry->getAttribute($attribute); + if (is_array($attr_values) && isset($attr_values[0])) { + $value = $attr_values[0]; + } elseif (!empty($attr_values)) { + $value = $attr_values; + } + } + + // Process based on mapping type + if ($mapping_type === 'user_reference') { + if (!empty($value)) { + // For user references, extract username from DN if needed + $username = $value; + if (stripos($value, 'uid=') !== FALSE) { + preg_match('/uid=([^,]+)/i', $value, $matches); + $username = $matches[1] ?? $value; + } + + // Convert username to Drupal user ID + $user_id = $this->getUserIdByUsername($username); + + if ($user_id) { + $dept_data[$field] = $user_id; + } else { + // User not found - this is normal if user doesn't exist in Drupal yet + // Log as debug instead of warning since this is expected behavior + $this->logger->debug('User @username not found in Drupal for field @field - reference will be empty', [ + '@username' => $username, + '@field' => $field, + ]); + $dept_data[$field] = null; + } + } else { + $dept_data[$field] = null; + } + } else { + $dept_data[$field] = $value; + } + } + + // Use code as array key for backwards compatibility + $code = $dept_data['field_dept_code'] ?? null; + if ($code) { + // Map field names to legacy keys + $result = [ + 'code' => $code, + 'name' => $dept_data['label'] ?? $dept_data['name'] ?? '', + 'acronym' => $dept_data['field_dept_acronym'] ?? '', + 'type' => $dept_data['field_dept_type'] ?? '', + 'phone' => $dept_data['field_dept_phone'] ?? '', + 'room' => $dept_data['field_dept_room'] ?? '', + 'mail' => $dept_data['field_dept_mail'] ?? '', + ]; + + // Include only extra mapped fields (not already covered above) + $basic_dept_fields = ['field_dept_code', 'label', 'name', 'field_dept_acronym', 'field_dept_type', 'field_dept_phone', 'field_dept_room', 'field_dept_mail']; + foreach ($dept_data as $key => $value) { + if (!in_array($key, $basic_dept_fields)) { + $result[$key] = $value; + } + } + + $departments[] = $result; + } + } + + return $departments; + } + + /** + * Gets Drupal user ID by username. + * + * @param string $username + * The username to search for. + * + * @return int|null + * The user ID if found, NULL otherwise. + */ + protected function getUserIdByUsername($username) { + if (empty($username)) { + return null; + } + + $user_storage = $this->entityTypeManager->getStorage('user'); + $users = $user_storage->loadByProperties(['name' => $username]); + + if (!empty($users)) { + $user = reset($users); + return $user->id(); + } + + return null; + } + + /** + * Applies hierarchy to departments based on configuration. + */ + protected function applyHierarchy() { + $config = $this->configFactory->get('ldap_departments_sync.settings'); + + // Check if hierarchy is enabled + if (!$config->get('enable_hierarchy')) { + $this->logger->info('Hierarchy is disabled. Skipping hierarchy application.'); + return; + } + + $parent_attribute = $config->get('parent_attribute'); + $child_attribute = $config->get('child_attribute'); + + if (empty($parent_attribute) || empty($child_attribute)) { + $this->logger->warning('Parent or child attribute not configured. Skipping hierarchy application.'); + return; + } + + $this->logger->info('Applying hierarchy for groups using parent attribute: @parent and child attribute: @child', [ + '@parent' => $parent_attribute, + '@child' => $child_attribute, + ]); + + // Fetch raw LDAP entries to get hierarchy attributes + $ldap_entries = $this->fetchRawLdapEntries(); + if (empty($ldap_entries)) { + $this->logger->warning('No LDAP entries found. Cannot apply hierarchy.'); + return; + } + + // Build hierarchy map from LDAP data + $hierarchy_map = $this->buildHierarchyMap($ldap_entries, $parent_attribute, $child_attribute); + + $this->applyGroupHierarchy($hierarchy_map); + } + + /** + * Fetches raw LDAP Entry objects from the query. + * + * @return array + * Array of Entry objects from LDAP. + */ + protected function fetchRawLdapEntries() { + $entries = []; + + // Verifica se o storage de queries está disponível + if (!$this->ldapQueryStorage) { + $this->logger->error('Storage de queries LDAP não está disponível.'); + return $entries; + } + + try { + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $query_id = $config->get('ldap_query_id') ?: 'department_sync'; + + $query_entity = $this->ldapQueryStorage->load($query_id); + + if (!$query_entity || !$query_entity->get('status')) { + $this->logger->error('Query LDAP não está disponível ou desabilitada.'); + return $entries; + } + + // Obtém parâmetros da query + $server_id = $query_entity->getServerId(); + $base_dns = $query_entity->getProcessedBaseDns(); + $filter = $query_entity->getFilter(); + $attributes = $query_entity->getProcessedAttributes(); + + // Configura o LdapBridge + $this->ldapBridge->setServerById($server_id); + + if (!$this->ldapBridge->bind()) { + throw new \Exception('Falha ao conectar ao servidor LDAP'); + } + + // Executa busca para cada Base DN + $all_results = []; + foreach ($base_dns as $base_dn) { + try { + $ldap = $this->ldapBridge->get(); + $attr_options = []; + if (!empty($attributes)) { + $attr_options = ['filter' => $attributes]; + } + + $query = $ldap->query($base_dn, $filter, $attr_options); + $base_results = $query->execute(); + + if ($base_results instanceof \Traversable) { + $base_results = iterator_to_array($base_results); + } + + if (!empty($base_results) && is_array($base_results)) { + $all_results = array_merge($all_results, $base_results); + } + } + catch (\Exception $e) { + $this->logger->error('Erro na busca LDAP para Base DN @base_dn: @message', [ + '@base_dn' => $base_dn, + '@message' => $e->getMessage(), + ]); + } + } + + $entries = $all_results; + } + catch (\Exception $e) { + $this->logger->error('Erro ao buscar entries LDAP: @message', ['@message' => $e->getMessage()]); + } + + return $entries; + } + + /** + * Builds a hierarchy map from LDAP department data. + * + * @param array $ldap_entries + * Array of LDAP Entry objects. + * @param string $parent_attribute + * LDAP attribute containing parent reference. + * @param string $child_attribute + * LDAP attribute containing child reference (not used currently). + * + * @return array + * Map of child codes to parent codes. + */ + protected function buildHierarchyMap(array $ldap_entries, $parent_attribute, $child_attribute) { + $hierarchy_map = []; + $config = $this->configFactory->get('ldap_departments_sync.settings'); + $attribute_mappings = $config->get('attribute_mappings') ?: []; + + // Find the LDAP attribute that maps to field_dept_code + $code_attribute = null; + foreach ($attribute_mappings as $mapping) { + if ($mapping['field'] === 'field_dept_code') { + $code_attribute = $mapping['attribute']; + break; + } + } + + if (!$code_attribute) { + $this->logger->error('No attribute mapping found for field_dept_code. Cannot build hierarchy.'); + return $hierarchy_map; + } + + foreach ($ldap_entries as $entry) { + // Get the department code from LDAP entry + $dept_code = null; + if ($entry->hasAttribute($code_attribute)) { + $code_values = $entry->getAttribute($code_attribute); + if (is_array($code_values) && isset($code_values[0])) { + $dept_code = $code_values[0]; + } elseif (!empty($code_values)) { + $dept_code = $code_values; + } + } + + if (!$dept_code) { + continue; + } + + // Get parent reference from LDAP + $parent_value = null; + if ($entry->hasAttribute($parent_attribute)) { + $parent_values = $entry->getAttribute($parent_attribute); + if (is_array($parent_values) && isset($parent_values[0])) { + $parent_value = $parent_values[0]; + } elseif (!empty($parent_values)) { + $parent_value = $parent_values; + } + } + + // Extract parent code from DN or direct value + if (!empty($parent_value)) { + // If parent is a DN, extract the code + if (preg_match('/imeccDepartmentCode=([^,]+)/i', $parent_value, $matches)) { + $parent_code = $matches[1]; + } else { + $parent_code = $parent_value; + } + $hierarchy_map[$dept_code] = $parent_code; + } + } + + $this->logger->info('Built hierarchy map with @count relationships', [ + '@count' => count($hierarchy_map), + ]); + + return $hierarchy_map; + } + + /** + * Applies hierarchy to groups using custom field_parent_group field. + * + * @param array $hierarchy_map + * Map of child codes to parent codes. + */ + protected function applyGroupHierarchy(array $hierarchy_map) { + $storage = $this->getEntityStorage(); + $updated = 0; + + $this->logger->info('Applying group hierarchy using custom field_parent_group field.'); + + foreach ($hierarchy_map as $child_code => $parent_code) { + $child_group = $this->getDepartmentByCode($child_code); + $parent_group = $this->getDepartmentByCode($parent_code); + + if ($child_group && $parent_group) { + // Check if child group has field_parent_group + if (!$child_group->hasField('field_parent_group')) { + $this->logger->warning('Group @child does not have field_parent_group. Please run database updates.', [ + '@child' => $child_group->label(), + ]); + continue; + } + + // Check if parent is already set correctly + $current_parent_id = $child_group->get('field_parent_group')->target_id; + if ($current_parent_id != $parent_group->id()) { + try { + // Set the parent group + $child_group->set('field_parent_group', $parent_group->id()); + $child_group->save(); + $updated++; + $this->logger->info('Set parent group for @child to @parent', [ + '@child' => $child_group->label(), + '@parent' => $parent_group->label(), + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to set parent for @child to @parent: @error', [ + '@child' => $child_group->label(), + '@parent' => $parent_group->label(), + '@error' => $e->getMessage(), + ]); + } + } else { + $this->logger->debug('Parent group already set for @child', [ + '@child' => $child_group->label(), + ]); + } + } else { + $this->logger->warning('Could not find groups for hierarchy relationship. Child code: @child_code, Parent code: @parent_code', [ + '@child_code' => $child_code, + '@parent_code' => $parent_code, + ]); + } + } + + $this->logger->info('Group hierarchy applied. Updated @count group relationships.', [ + '@count' => $updated, + ]); + } + + /** + * Returns the group entity storage. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + * Entity storage for group. + */ + protected function getEntityStorage() { + return $this->entityTypeManager->getStorage('group'); + } + + /** + * Returns the group type ID from configuration. + * + * @return string + * The group type ID. + */ + protected function getBundleId() { + $config = $this->configFactory->get('ldap_departments_sync.settings'); + return $config->get('group_type_id') ?: 'departments'; + } + + /** + * Returns the name field for groups. + * + * @return string + * Always returns 'label' for groups. + */ + protected function getNameField() { + return 'label'; + } + + /** + * Returns the bundle field name for groups. + * + * @return string + * Always returns 'type' for groups. + */ + protected function getBundleField() { + return 'type'; + } + +} diff --git a/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php b/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php new file mode 100644 index 0000000..4f6a5af --- /dev/null +++ b/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php @@ -0,0 +1,105 @@ + FALSE, + 'allowed_types' => [], + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + // Força departments como bundle padrão + $form['target_bundles']['#default_value'] = ['departments' => 'departments']; + + $configuration = $this->getConfiguration(); + + $form['filter_by_type'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Filter by department type'), + '#description' => $this->t('Only show departments of specific types (field_dept_type).'), + '#default_value' => $configuration['filter_by_type'], + '#weight' => 10, + ]; + + $form['allowed_types'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed department types'), + '#options' => [ + 'academico' => $this->t('Acadêmico'), + 'administrativo' => $this->t('Administrativo'), + ], + '#default_value' => $configuration['allowed_types'], + '#weight' => 11, + '#states' => [ + 'visible' => [ + ':input[name="settings[handler_settings][filter_by_type]"]' => ['checked' => TRUE], + ], + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::validateConfigurationForm($form, $form_state); + + // Remove valores vazios de allowed_types + $allowed_types = $form_state->getValue(['settings', 'handler_settings', 'allowed_types']); + if (is_array($allowed_types)) { + $allowed_types = array_filter($allowed_types); + $form_state->setValue(['settings', 'handler_settings', 'allowed_types'], $allowed_types); + } + } + + /** + * {@inheritdoc} + */ + protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') { + $query = parent::buildEntityQuery($match, $match_operator); + + $configuration = $this->getConfiguration(); + + // Força apenas departments + $query->condition('type', 'departments'); + + // Filtra por tipo de departamento se configurado + if (!empty($configuration['filter_by_type']) && !empty($configuration['allowed_types'])) { + $allowed_types = array_filter($configuration['allowed_types']); + if (!empty($allowed_types)) { + $query->condition('field_dept_type', array_keys($allowed_types), 'IN'); + } + } + + return $query; + } + +} diff --git a/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po b/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po new file mode 100644 index 0000000..d4e4a04 --- /dev/null +++ b/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po @@ -0,0 +1,286 @@ +# Portuguese (Brazil) translation for LDAP Departments Sync module +# Copyright (c) 2025 +# This file is distributed under the same license as the LDAP Departments Sync module. +# +msgid "" +msgstr "" +"Project-Id-Version: LDAP Departments Sync 1.0.0\n" +"POT-Creation-Date: 2025-11-07 12:00+0000\n" +"PO-Revision-Date: 2025-11-07 12:00+0000\n" +"Language-Team: Portuguese (Brazil)\n" +"Language: pt-br\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: src/Controller/LocalModulesController.php +msgid "Local Modules" +msgstr "Módulos Locais" + +#: ldap_departments_sync.routing.yml +msgid "LDAP Departments Sync" +msgstr "Sincronização de Departamentos LDAP" + +#: ldap_departments_sync.module +msgid "This module synchronizes departments from an LDAP server to groups through cron." +msgstr "Este módulo sincroniza departamentos de um servidor LDAP para grupos através do cron." + +#: src/Form/LdapDepartmentsSyncConfigForm.php + +msgid "Manage Fields" +msgstr "Gerenciar Campos" + +msgid "LDAP Server" +msgstr "Servidor LDAP" + +msgid "Select the LDAP server configured for departments synchronization." +msgstr "Selecione o servidor LDAP configurado para sincronização de departamentos." + +msgid "- Select a server -" +msgstr "- Selecione um servidor -" + +msgid "No LDAP server found. Configure an LDAP server first." +msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro." + +msgid "LDAP Query" +msgstr "Consulta LDAP" + +msgid "Select the LDAP query that will be used to search for departments." +msgstr "Selecione a consulta LDAP que será usada para buscar departamentos." + +msgid "- Select a query -" +msgstr "- Selecione uma query -" + +msgid "No LDAP query found for the selected server. Create a new LDAP query." +msgstr "Nenhuma query LDAP encontrada para o servidor selecionado. Crie uma nova query LDAP." + +msgid "Create New LDAP Query" +msgstr "Criar Nova Query LDAP" + +msgid "Edit Selected Query" +msgstr "Editar Query Selecionada" + +msgid "Hierarchy" +msgstr "Hierarquia" + +msgid "Configure whether departments should be organized hierarchically based on LDAP attributes." +msgstr "Configure se os departamentos devem ser organizados hierarquicamente baseado em atributos LDAP." + +msgid "Enable hierarchy" +msgstr "Habilitar hierarquização" + +msgid "When enabled, departments will be organized hierarchically based on the specified parent and child attributes." +msgstr "Quando habilitado, os departamentos serão organizados hierarquicamente baseado nos atributos parent e child especificados." + +msgid "Parent Attribute" +msgstr "Atributo Pai" + +msgid "LDAP attribute that contains the parent department code (e.g. departmentNumber)." +msgstr "Atributo LDAP que contém o código do departamento pai (ex: departmentNumber)." + +msgid "- Select an attribute -" +msgstr "- Selecione um atributo -" + +msgid "Child Attribute" +msgstr "Atributo Filho" + +msgid "LDAP attribute that contains the department's own code (e.g. imeccDepartmentCode)." +msgstr "Atributo LDAP que contém o código próprio do departamento (ex: imeccDepartmentCode)." + +msgid "All elements with the same child value will be nested under the corresponding parent recursively." +msgstr "Todos os elementos com o mesmo valor de child serão aninhados sob o parent correspondente recursivamente." + +msgid "Attribute Mapping" +msgstr "Mapeamento de Atributos" + +msgid "Configure how LDAP attributes are mapped to group fields.
Note: For \"User Reference\" mappings, the search attribute will be automatically obtained from the \"Account name attribute\" field (or \"Authentication name attribute\" as fallback) configured in the selected LDAP server." +msgstr "Configure como os atributos LDAP são mapeados para os campos do grupo.
Nota: Para mapeamentos de \"Referência de Usuário\", o atributo de busca será automaticamente obtido do campo \"Account name attribute\" (ou \"Authentication name attribute\" como fallback) configurado no servidor LDAP selecionado." + +msgid "Entity Field" +msgstr "Campo da Entidade" + +msgid "Group Field" +msgstr "Campo do Grupo" + +msgid "LDAP Attribute" +msgstr "Atributo LDAP" + +msgid "Mapping Type" +msgstr "Tipo de Mapeamento" + +msgid "Remove" +msgstr "Remover" + +msgid "No mappings configured." +msgstr "Nenhum mapeamento configurado." + +msgid "- Select a field -" +msgstr "- Selecione um campo -" + +msgid "Simple (text)" +msgstr "Simples (texto)" + +msgid "User Reference (DN → User)" +msgstr "Referência de Usuário (DN → User)" + +msgid "Add Mapping" +msgstr "Adicionar Mapeamento" + +msgid "Remove Selected" +msgstr "Remover Selecionados" + +msgid "Save Configuration" +msgstr "Salvar Configurações" + +msgid "Test Connection" +msgstr "Testar Conexão" + +msgid "Synchronize Departments" +msgstr "Sincronizar Departamentos" + +msgid "Error loading LDAP servers: @message" +msgstr "Erro ao carregar servidores LDAP: @message" + +msgid "Error loading group fields: @message" +msgstr "Erro ao carregar campos do grupo: @message" + +msgid "Error loading query attributes: @message" +msgstr "Erro ao carregar atributos da query: @message" + +msgid "LDAP attribute is required when field is selected." +msgstr "Atributo LDAP é obrigatório quando campo está selecionado." + +msgid "Group field is required when attribute is selected." +msgstr "Campo do grupo é obrigatório quando atributo está selecionado." + +msgid "Configuration saved successfully." +msgstr "Configurações salvas com sucesso." + +msgid "Group Role Mapping" +msgstr "Mapeamento de Papéis em Grupos" + +msgid "Configure how user attributes map to group roles. Each role can have its own mapping criteria." +msgstr "Configure como atributos de usuário mapeiam para papéis em grupos. Cada papel pode ter seus próprios critérios de mapeamento." + +msgid "Configure how to assign group roles based on LDAP attributes or user fields." +msgstr "Configure como atribuir papéis em grupos baseado em atributos LDAP ou campos de usuário." + +msgid "Enable role mapping" +msgstr "Habilitar mapeamento de papéis" + +msgid "When enabled, users will be assigned group roles based on the mappings below." +msgstr "Quando habilitado, os usuários receberão papéis em grupos baseado nos mapeamentos abaixo." + +msgid "Group Role" +msgstr "Papel do Grupo" + +msgid "Source" +msgstr "Origem" + +msgid "LDAP Attribute" +msgstr "Atributo LDAP" + +msgid "User Field" +msgstr "Campo do Usuário" + +msgid "Values (comma-separated)" +msgstr "Valores (separados por vírgula)" + +msgid "No role mappings configured." +msgstr "Nenhum mapeamento de papel configurado." + +msgid "- Select a role -" +msgstr "- Selecione um papel -" + +msgid "Add Role Mapping" +msgstr "Adicionar Mapeamento de Papel" + +msgid "Remove Selected" +msgstr "Remover Selecionados" + +msgid "Default Group Role" +msgstr "Papel Padrão do Grupo" + +msgid "The default role to assign if no role mapping matches." +msgstr "O papel padrão a ser atribuído se nenhum mapeamento corresponder." + +msgid "Enter the expected values for this field/attribute, separated by commas (e.g., admin,administrator,manager)." +msgstr "Digite os valores esperados para este campo/atributo, separados por vírgula (ex: admin,administrador,gerente)." + +msgid "Internal" +msgstr "Interno" + +msgid "Outsider" +msgstr "Externo" + +msgid "- Select attribute/field -" +msgstr "- Selecione atributo/campo -" + +msgid "Group Field Match" +msgstr "Correspondência de Campo do Grupo" + +msgid "User Field / LDAP Attribute" +msgstr "Campo do Usuário / Atributo LDAP" + +msgid "Group Field / Values" +msgstr "Campo do Grupo / Valores" + +msgid "- Select group field -" +msgstr "- Selecione campo do grupo -" + +msgid "- Select user field -" +msgstr "- Selecione campo do usuário -" + +msgid "The default \"departments\" group type is not installed." +msgstr "O tipo de grupo padrão \"departments\" não está instalado." + +msgid "Install Default Configuration" +msgstr "Instalar Configuração Padrão" + +msgid "The button above will create:Or you can create a custom group type manually." +msgstr "O botão acima irá criar:Ou você pode criar um tipo de grupo personalizado manualmente." + +msgid "Successfully installed @count configuration items. Group type \"departments\" and all required fields have been created. Please reload the page to see the changes." +msgstr "@count itens de configuração instalados com sucesso. O tipo de grupo \"departments\" e todos os campos necessários foram criados. Por favor, recarregue a página para ver as alterações." + +msgid "All configurations already exist. Nothing was imported." +msgstr "Todas as configurações já existem. Nada foi importado." + +msgid "Error installing default configuration: @message" +msgstr "Erro ao instalar configuração padrão: @message" + +msgid "Configuration directory not found: @path" +msgstr "Diretório de configuração não encontrado: @path" + +msgid "Configuration @name already exists, skipping." +msgstr "Configuração @name já existe, ignorando." + +#: src/Plugin/EntityReferenceSelection/DepartmentSelection.php +msgid "Filter by department type" +msgstr "Filtrar por tipo de departamento" + +msgid "Only show departments of specific types (field_dept_type)." +msgstr "Mostrar apenas departamentos de tipos específicos (field_dept_type)." + +msgid "Allowed department types" +msgstr "Tipos de departamento permitidos" + +msgid "Acadêmico" +msgstr "Acadêmico" + +msgid "Administrativo" +msgstr "Administrativo" + +msgid "Department selection" +msgstr "Seleção de departamento" + +#: ldap_departments_sync.links.task.yml +msgid "Configuration" +msgstr "Configuração" + +msgid "Access Rules" +msgstr "Regras de Acesso" + +msgid "Access Rule" +msgstr "Regra de Acesso" \ No newline at end of file diff --git a/ldap_departments_sync/translations/pt-br.po b/ldap_departments_sync/translations/pt-br.po new file mode 100644 index 0000000..4414fcf --- /dev/null +++ b/ldap_departments_sync/translations/pt-br.po @@ -0,0 +1,152 @@ +# Portuguese translation of LDAP Departments Sync +# Copyright (c) 2025 +msgid "" +msgstr "" +"Project-Id-Version: LDAP Departments Sync 1.0.0\n" +"POT-Creation-Date: 2025-01-12 10:00-0300\n" +"PO-Revision-Date: 2025-01-12 10:00-0300\n" +"Language-Team: Portuguese (Brazil)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"Language: pt_BR\n" + +# Form titles and sections +msgid "Synchronization Type" +msgstr "Tipo de Sincronização" + +msgid "Data storage type" +msgstr "Tipo de armazenamento de dados" + +msgid "Choose whether departments should be synchronized as taxonomy terms or as groups." +msgstr "Escolha se os departamentos devem ser sincronizados como termos de taxonomia ou como grupos." + +msgid "Taxonomy Vocabulary - Store departments as taxonomy terms" +msgstr "Vocabulário de Taxonomia - Armazenar departamentos como termos de taxonomia" + +msgid "Groups - Store departments as group entities" +msgstr "Grupos - Armazenar departamentos como entidades de grupo" + +msgid "Updating form..." +msgstr "Atualizando formulário..." + +msgid "Taxonomy Vocabulary" +msgstr "Vocabulário de Taxonomia" + +msgid "Target Vocabulary" +msgstr "Vocabulário de Destino" + +msgid "Select the taxonomy vocabulary where departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail" +msgstr "Selecione o vocabulário de taxonomia onde os departamentos serão sincronizados.
Campos obrigatórios: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail" + +msgid "- Select a vocabulary -" +msgstr "- Selecione um vocabulário -" + +msgid "No taxonomy vocabulary found. Create a vocabulary first." +msgstr "Nenhum vocabulário de taxonomia encontrado. Crie um vocabulário primeiro." + +msgid "Create New Vocabulary" +msgstr "Criar Novo Vocabulário" + +msgid "Manage Vocabulary" +msgstr "Gerenciar Vocabulário" + +msgid "Manage Fields" +msgstr "Gerenciar Campos" + +msgid "Group Type" +msgstr "Tipo de Grupo" + +msgid "Target Group Type" +msgstr "Tipo de Grupo de Destino" + +msgid "Select the group type where departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail" +msgstr "Selecione o tipo de grupo onde os departamentos serão sincronizados.
Campos obrigatórios: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail" + +msgid "- Select a group type -" +msgstr "- Selecione um tipo de grupo -" + +msgid "No group type found. Create a group type first." +msgstr "Nenhum tipo de grupo encontrado. Crie um tipo de grupo primeiro." + +msgid "Create New Group Type" +msgstr "Criar Novo Tipo de Grupo" + +msgid "Manage Group Type" +msgstr "Gerenciar Tipo de Grupo" + +msgid "LDAP Server" +msgstr "Servidor LDAP" + +msgid "Select the LDAP server configured for departments synchronization." +msgstr "Selecione o servidor LDAP configurado para sincronização de departamentos." + +msgid "- Select a server -" +msgstr "- Selecione um servidor -" + +msgid "No LDAP server found. Configure an LDAP server first." +msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro." + +msgid "LDAP Query" +msgstr "Consulta LDAP" + +msgid "Select the LDAP query that will be used to search for departments." +msgstr "Selecione a consulta LDAP que será usada para buscar departamentos." + +msgid "- Select a query -" +msgstr "- Selecione uma consulta -" + +msgid "No LDAP query found for the selected server. Create a new LDAP query." +msgstr "Nenhuma consulta LDAP encontrada para o servidor selecionado. Crie uma nova consulta LDAP." + +msgid "Create New LDAP Query" +msgstr "Criar Nova Consulta LDAP" + +msgid "Edit Selected Query" +msgstr "Editar Consulta Selecionada" + +msgid "Hierarchy" +msgstr "Hierarquia" + +msgid "Configure whether departments should be organized hierarchically based on LDAP attributes." +msgstr "Configure se os departamentos devem ser organizados hierarquicamente com base em atributos LDAP." + +msgid "Enable hierarchy" +msgstr "Habilitar hierarquia" + +msgid "When enabled, terms will be organized hierarchically based on the specified parent and child attributes." +msgstr "Quando habilitado, os departamentos serão organizados hierarquicamente com base nos atributos de pai e filho especificados." + +msgid "Parent Attribute" +msgstr "Atributo Pai" + +msgid "LDAP attribute that contains the parent department code (e.g. departmentNumber)." +msgstr "Atributo LDAP que contém o código do departamento pai (ex: departmentNumber)." + +msgid "Child Attribute" +msgstr "Atributo Filho" + +msgid "LDAP attribute that contains the department's own code (e.g. imeccDepartmentCode)." +msgstr "Atributo LDAP que contém o código próprio do departamento (ex: imeccDepartmentCode)." + +msgid "- Select an attribute -" +msgstr "- Selecione um atributo -" + +msgid "All elements with the same child value will be nested under the corresponding parent recursively." +msgstr "Todos os elementos com o mesmo valor filho serão aninhados sob o pai correspondente recursivamente." + +msgid "Attribute Mapping" +msgstr "Mapeamento de Atributos" + +msgid "Configure how LDAP attributes are mapped to target entity fields.
Note: For \"User Reference\" mappings, the search attribute will be automatically obtained from the \"Account name attribute\" field (or \"Authentication name attribute\" as fallback) configured in the selected LDAP server." +msgstr "Configure como os atributos LDAP são mapeados para os campos da entidade de destino.
Nota: Para mapeamentos de \"Referência de Usuário\", o atributo de busca será obtido automaticamente do campo \"Account name attribute\" (ou \"Authentication name attribute\" como alternativa) configurado no servidor LDAP selecionado." + +msgid "- Select a field -" +msgstr "- Selecione um campo -" + +msgid "Simple (text)" +msgstr "Simples (texto)" + +msgid "User Reference (DN → User)" +msgstr "Referência de Usuário (DN → Usuário)" diff --git a/ldap_groups_sync.info.yml b/ldap_groups_sync.info.yml new file mode 100644 index 0000000..7e8d8c8 --- /dev/null +++ b/ldap_groups_sync.info.yml @@ -0,0 +1,11 @@ +name: LDAP Groups Sync +type: module +description: 'Base module with shared access-rules infrastructure for LDAP group sync modules.' +core_version_requirement: ^11 +package: Custom +dependencies: + - drupal:options + - drupal:telephone + - group:group + - ldap:ldap_servers + - site_tools diff --git a/ldap_research_groups_sync/README.md b/ldap_research_groups_sync/README.md new file mode 100644 index 0000000..981caa8 --- /dev/null +++ b/ldap_research_groups_sync/README.md @@ -0,0 +1,69 @@ +# LDAP Research Groups Sync + +Módulo Drupal 11 que sincroniza grupos de pesquisa do servidor LDAP corporativo com entidades do tipo `research_group` (módulo Group). + +## Funcionalidades + +- Criação e atualização automática de grupos de pesquisa via cron a partir de uma LDAP query configurável +- Sincronização de membros dos grupos a partir do atributo `member` (ou outro configurável) de cada entrada LDAP +- Regras de acesso configuráveis por grupo +- Proteção dos campos LDAP do usuário contra edição não autorizada + +## Dependências + +- `drupal:options` +- `drupal:telephone` +- `group:group` +- `ldap:ldap_servers` +- `site_tools` + +## Campos criados + +### No grupo (`research_group`) + +| Campo | Tipo | Descrição | +|---|---|---| +| `field_rg_code` | String | Código do grupo (chave de sincronização) | +| `field_rg_phone` | Telephone | Telefone | +| `field_rg_mail` | Email | E-mail | +| `field_rg_coord` | Entity reference (user) | Coordenador do grupo | +| `field_rg_coord_assoc` | Entity reference (user) | Coordenador associado do grupo | +| `field_rg_department` | Entity reference (group) | Departamento ao qual o grupo está vinculado | + +### No usuário + +| Campo | Tipo | Descrição | +|---|---|---| +| `field_user_research_groups` | Entity reference (group, múltiplos) | Grupos de pesquisa do usuário (populado pelo sync) | + +## Instalação + +```bash +drush en ldap_research_groups_sync +drush cr +``` + +## Configuração + +Acesse `/admin/config/local-modules/ldap-research-groups-sync` e configure: + +1. **Group Type** — selecione `research_group` +2. **LDAP Server** — servidor LDAP a ser usado +3. **LDAP Query** — query entity que retorna as entradas dos grupos de pesquisa +4. **Attribute Mappings** — mapeamento entre atributos LDAP e campos do grupo +5. **Member Synchronization** — habilite e defina o atributo LDAP de membros (padrão: `member`) + +> **Atenção:** o atributo de membros (ex.: `member`) deve estar incluído nos atributos retornados pela LDAP query, ou a query não deve ter filtro de atributos. + +## Sincronização manual + +```bash +drush php-eval "\Drupal::service('ldap_research_groups_sync.sync')->syncResearchGroups();" +``` + +A sincronização também é executada automaticamente pelo cron. + +## Permissões + +- `administer ldap research groups sync` — acesso à página de configuração +- `edit ldap managed user rg fields` — permite editar o campo `field_user_research_groups` manualmente (normalmente reservado ao sync) diff --git a/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml b/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml new file mode 100644 index 0000000..707bbe0 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml @@ -0,0 +1,102 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.field.group.research_group.field_rg_code + - field.field.group.research_group.field_rg_coord + - field.field.group.research_group.field_rg_coord_assoc + - field.field.group.research_group.field_rg_mail + - field.field.group.research_group.field_rg_phone + - field.field.group.research_group.field_rg_department + - group.type.research_group + module: + - path +id: group.research_group.default +targetEntityType: group +bundle: research_group +mode: default +content: + field_rg_code: + type: string_textfield + weight: 122 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_rg_mail: + type: email_default + weight: 125 + region: content + settings: + placeholder: '' + size: 60 + third_party_settings: { } + field_rg_phone: + type: string_textfield + weight: 124 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_rg_coord: + type: entity_reference_autocomplete + weight: 126 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_rg_coord_assoc: + type: entity_reference_autocomplete + weight: 127 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_rg_department: + type: entity_reference_autocomplete + weight: 25 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + label: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + langcode: + type: language_select + weight: 2 + region: content + settings: + include_locked: true + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 120 + region: content + settings: + display_label: true + third_party_settings: { } +hidden: + uid: true diff --git a/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml b/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml new file mode 100644 index 0000000..1420300 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml @@ -0,0 +1,81 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.field.group.research_group.field_rg_code + - field.field.group.research_group.field_rg_coord + - field.field.group.research_group.field_rg_coord_assoc + - field.field.group.research_group.field_rg_mail + - field.field.group.research_group.field_rg_phone + - field.field.group.research_group.field_rg_department + - group.type.research_group + module: + - options +id: group.research_group.default +targetEntityType: group +bundle: research_group +mode: default +content: + field_rg_code: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -3 + region: content + field_rg_mail: + type: basic_string + label: above + settings: { } + third_party_settings: { } + weight: 0 + region: content + field_rg_phone: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: -1 + region: content + field_rg_coord: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 1 + region: content + field_rg_coord_assoc: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 2 + region: content + field_rg_department: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 25 + region: content + label: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: { } + weight: -5 + region: content +hidden: + changed: true + created: true + entity_print_view_epub: true + entity_print_view_pdf: true + entity_print_view_word_docx: true + langcode: true + uid: true diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml new file mode 100644 index 0000000..c5a4390 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_code + - group.type.research_group +id: group.research_group.field_rg_code +field_name: field_rg_code +entity_type: group +bundle: research_group +label: 'Código' +description: 'Código do grupo de pesquisa (chave de sincronização LDAP)' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml new file mode 100644 index 0000000..f3b8210 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml @@ -0,0 +1,30 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_coord + - group.type.research_group + module: + - user +id: group.research_group.field_rg_coord +field_name: field_rg_coord +entity_type: group +bundle: research_group +label: Coordenador +description: 'Usuário responsável pela coordenação do grupo de pesquisa' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:user' + handler_settings: + target_bundles: null + sort: + field: _none + direction: ASC + auto_create: false + filter: + type: _none + include_anonymous: false +field_type: entity_reference diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml new file mode 100644 index 0000000..adb5174 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml @@ -0,0 +1,30 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_coord_assoc + - group.type.research_group + module: + - user +id: group.research_group.field_rg_coord_assoc +field_name: field_rg_coord_assoc +entity_type: group +bundle: research_group +label: 'Coordenador Associado' +description: 'Usuário responsável pela coordenação associada do grupo de pesquisa' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:user' + handler_settings: + target_bundles: null + sort: + field: _none + direction: ASC + auto_create: false + filter: + type: _none + include_anonymous: false +field_type: entity_reference diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml new file mode 100644 index 0000000..fd71774 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml @@ -0,0 +1,28 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_department + - group.type.department + - group.type.research_group +id: group.research_group.field_rg_department +field_name: field_rg_department +entity_type: group +bundle: research_group +label: Departamento +description: 'Departamento ao qual este grupo de pesquisa está vinculado' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:group' + handler_settings: + target_bundles: + department: department + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml new file mode 100644 index 0000000..56a614a --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_mail + - group.type.research_group +id: group.research_group.field_rg_mail +field_name: field_rg_mail +entity_type: group +bundle: research_group +label: 'E-mail' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: email diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml new file mode 100644 index 0000000..c870b2d --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.group.field_rg_phone + - group.type.research_group + module: + - telephone +id: group.research_group.field_rg_phone +field_name: field_rg_phone +entity_type: group +bundle: research_group +label: 'Telefone' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: telephone diff --git a/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml b/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml new file mode 100644 index 0000000..c4dfe40 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml @@ -0,0 +1,28 @@ +langcode: pt-br +status: true +dependencies: + config: + - field.storage.user.field_user_research_groups + module: + - user +id: user.user.field_user_research_groups +field_name: field_user_research_groups +entity_type: user +bundle: user +label: 'Grupos de Pesquisa' +description: 'Grupos de pesquisa do usuário sincronizados do LDAP' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:group' + handler_settings: + target_bundles: + research_group: research_group + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml new file mode 100644 index 0000000..cb38c77 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml @@ -0,0 +1,20 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_rg_code +field_name: field_rg_code +entity_type: group +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml new file mode 100644 index 0000000..e23e052 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml @@ -0,0 +1,19 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - user +id: group.field_rg_coord +field_name: field_rg_coord +entity_type: group +type: entity_reference +settings: + target_type: user +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml new file mode 100644 index 0000000..d619ba1 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml @@ -0,0 +1,19 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - user +id: group.field_rg_coord_assoc +field_name: field_rg_coord_assoc +entity_type: group +type: entity_reference +settings: + target_type: user +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml new file mode 100644 index 0000000..b3cb2f1 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_rg_department +field_name: field_rg_department +entity_type: group +type: entity_reference +settings: + target_type: group +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml new file mode 100644 index 0000000..caf7cbc --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + module: + - group +id: group.field_rg_mail +field_name: field_rg_mail +entity_type: group +type: email +settings: { } +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml new file mode 100644 index 0000000..2fc1aeb --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml @@ -0,0 +1,18 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - telephone +id: group.field_rg_phone +field_name: field_rg_phone +entity_type: group +type: telephone +settings: { } +module: telephone +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml b/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml new file mode 100644 index 0000000..1f61695 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml @@ -0,0 +1,19 @@ +langcode: pt-br +status: true +dependencies: + module: + - group + - user +id: user.field_user_research_groups +field_name: field_user_research_groups +entity_type: user +type: entity_reference +settings: + target_type: group +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml new file mode 100644 index 0000000..aadd213 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml @@ -0,0 +1,37 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group +id: research_group-admin +label: Admin +weight: 100 +admin: true +scope: individual +global_role: null +group_type: research_group +permissions: + - administer members + - delete group + - edit group + - leave group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete own group_node:article entity + - delete own group_node:page entity + - update any group_node:article entity + - update any group_node:page entity + - update own group_node:article entity + - update own group_node:page entity + - view group_node:article entity + - view group_node:page entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml new file mode 100644 index 0000000..cd121b8 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml @@ -0,0 +1,38 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group + - user.role.administrator +id: research_group-admin_in +label: Administrador +weight: 102 +admin: true +scope: insider +global_role: administrator +group_type: research_group +permissions: + - administer members + - delete group + - edit group + - leave group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete own group_node:article entity + - delete own group_node:page entity + - update any group_node:article entity + - update any group_node:page entity + - update own group_node:article entity + - update own group_node:page entity + - view group_node:article entity + - view group_node:page entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml new file mode 100644 index 0000000..9042a86 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml @@ -0,0 +1,38 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group + - user.role.administrator +id: research_group-admin_out +label: Administrador +weight: 101 +admin: true +scope: outsider +global_role: administrator +group_type: research_group +permissions: + - administer members + - delete group + - edit group + - join group + - view group + - view unpublished group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - delete any group_node:article entity + - delete any group_node:page entity + - delete own group_node:article entity + - delete own group_node:page entity + - update any group_node:article entity + - update any group_node:page entity + - update own group_node:article entity + - update own group_node:page entity + - view group_node:article entity + - view group_node:page entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity + - view unpublished group_node:article entity + - view unpublished group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml new file mode 100644 index 0000000..7da6323 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group + - user.role.anonymous +id: research_group-anonymous +label: 'Anônimo' +weight: -102 +admin: false +scope: outsider +global_role: anonymous +group_type: research_group +permissions: + - view group + - view group_node:article entity + - view group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml new file mode 100644 index 0000000..4eefd73 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml @@ -0,0 +1,27 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group + - user.role.authenticated +id: research_group-member +label: Member +weight: -100 +admin: false +scope: insider +global_role: authenticated +group_type: research_group +permissions: + - view group + - access group_media overview + - access group_node overview + - create group_node:article entity + - create group_node:page entity + - delete own group_node:article entity + - delete own group_node:page entity + - update own group_node:article entity + - update own group_node:page entity + - view group_node:article entity + - view group_node:page entity + - view own unpublished group_node:article entity + - view own unpublished group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml new file mode 100644 index 0000000..a7b2f0b --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml @@ -0,0 +1,17 @@ +langcode: pt-br +status: true +dependencies: + config: + - group.type.research_group + - user.role.authenticated +id: research_group-outsider +label: Outsider +weight: -101 +admin: false +scope: outsider +global_role: authenticated +group_type: research_group +permissions: + - view group + - view group_node:article entity + - view group_node:page entity diff --git a/ldap_research_groups_sync/config/optional/group.type.research_group.yml b/ldap_research_groups_sync/config/optional/group.type.research_group.yml new file mode 100644 index 0000000..1531186 --- /dev/null +++ b/ldap_research_groups_sync/config/optional/group.type.research_group.yml @@ -0,0 +1,10 @@ +langcode: pt-br +status: true +dependencies: { } +id: research_group +label: 'Grupo de Pesquisa' +description: '' +new_revision: true +creator_membership: true +creator_wizard: true +creator_roles: { } diff --git a/ldap_research_groups_sync/css/role-mapping.css b/ldap_research_groups_sync/css/role-mapping.css new file mode 100644 index 0000000..f5f17b1 --- /dev/null +++ b/ldap_research_groups_sync/css/role-mapping.css @@ -0,0 +1,73 @@ +/** + * Role mapping table styles + */ + +/* Limita a largura da tabela e permite scroll horizontal se necessário */ +.role-mappings-table { + max-width: 100%; + table-layout: fixed; +} + +/* Define larguras fixas para cada coluna */ +.role-mappings-table th:nth-child(1), +.role-mappings-table td:nth-child(1) { + width: 20%; + min-width: 150px; +} + +.role-mappings-table th:nth-child(2), +.role-mappings-table td:nth-child(2) { + width: 15%; + min-width: 130px; +} + +.role-mappings-table th:nth-child(3), +.role-mappings-table td:nth-child(3) { + width: 25%; + min-width: 180px; +} + +.role-mappings-table th:nth-child(4), +.role-mappings-table td:nth-child(4) { + width: 30%; + min-width: 200px; +} + +.role-mappings-table th:nth-child(5), +.role-mappings-table td:nth-child(5) { + width: 10%; + min-width: 80px; + text-align: center; +} + +/* Estilo dos selects para limitar largura e truncar texto */ +.role-mapping-field-select { + max-width: 100%; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Estilo para options dentro dos selects - mostra texto completo no dropdown */ +.role-mapping-field-select option { + white-space: normal; + overflow: visible; + text-overflow: clip; +} + +/* Estilo do textarea */ +.role-mapping-values-textarea { + max-width: 100%; + width: 100%; + min-height: 50px; +} + +/* Responsividade: em telas menores, permite scroll horizontal */ +@media (max-width: 1200px) { + .role-mappings-table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.info.yml b/ldap_research_groups_sync/ldap_research_groups_sync.info.yml new file mode 100644 index 0000000..bb08431 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.info.yml @@ -0,0 +1,13 @@ +name: LDAP Research Groups Sync +type: module +description: 'Sincroniza grupos de pesquisa do servidor LDAP com grupos via cron' +version: '1.0.0' +core_version_requirement: ^11 +package: Custom +dependencies: + - ldap_groups_sync:ldap_groups_sync + - drupal:options + - drupal:telephone + - group:group + - ldap:ldap_servers + - site_tools diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.install b/ldap_research_groups_sync/ldap_research_groups_sync.install new file mode 100644 index 0000000..00ef3c9 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.install @@ -0,0 +1,94 @@ +addWarning(t('Please ensure your group type has the required fields: field_rg_code, field_rg_phone, field_rg_mail, and field_rg_department (for department reference).')); +} + + +/** + * Adiciona os campos field_rg_coord e field_rg_coord_assoc ao bundle research_group. + */ +function ldap_research_groups_sync_update_10001() { + $configs = [ + 'field.storage.group.field_rg_coord', + 'field.storage.group.field_rg_coord_assoc', + 'field.field.group.research_group.field_rg_coord', + 'field.field.group.research_group.field_rg_coord_assoc', + ]; + + $module_path = \Drupal::service('extension.list.module')->getPath('ldap_research_groups_sync'); + $config_path = $module_path . '/config/optional'; + $config_factory = \Drupal::configFactory(); + $yaml_parser = \Drupal::service('serialization.yaml'); + $imported = 0; + + foreach ($configs as $config_name) { + if (!$config_factory->get($config_name)->isNew()) { + continue; + } + $file = $config_path . '/' . $config_name . '.yml'; + $data = $yaml_parser->decode(file_get_contents($file)); + $config_factory->getEditable($config_name)->setData($data)->save(); + $imported++; + } + + if ($imported > 0) { + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + } + + return t('Campos field_rg_coord e field_rg_coord_assoc importados (@count configs).', ['@count' => $imported]); +} + + +/** + * Adiciona o campo field_rg_code ao bundle research_group. + */ +function ldap_research_groups_sync_update_10002() { + $configs = [ + 'field.storage.group.field_rg_code', + 'field.field.group.research_group.field_rg_code', + ]; + + $module_path = \Drupal::service('extension.list.module')->getPath('ldap_research_groups_sync'); + $config_path = $module_path . '/config/optional'; + $config_factory = \Drupal::configFactory(); + $yaml_parser = \Drupal::service('serialization.yaml'); + $imported = 0; + + foreach ($configs as $config_name) { + if (!$config_factory->get($config_name)->isNew()) { + continue; + } + $file = $config_path . '/' . $config_name . '.yml'; + $data = $yaml_parser->decode(file_get_contents($file)); + $config_factory->getEditable($config_name)->setData($data)->save(); + $imported++; + } + + if ($imported > 0) { + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + } + + return t('Campo field_rg_code importado (@count configs).', ['@count' => $imported]); +} + + +/** + * Implements hook_uninstall(). + */ +function ldap_research_groups_sync_uninstall() { + // Remove configurações + \Drupal::configFactory()->getEditable('ldap_research_groups_sync.settings')->delete(); + + \Drupal::messenger()->addStatus(t('LDAP Research Groups Sync module uninstalled.')); +} diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml b/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml new file mode 100644 index 0000000..da24478 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml @@ -0,0 +1,5 @@ +role_mapping_styles: + version: 1.x + css: + theme: + css/role-mapping.css: {} diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml b/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml new file mode 100644 index 0000000..9687b76 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml @@ -0,0 +1,6 @@ +ldap_research_groups_sync.config: + title: 'LDAP Research Groups Sync' + description: 'Configurar sincronização de grupos de pesquisa do LDAP' + route_name: ldap_research_groups_sync.config + parent: site_tools.admin_config + weight: 6 diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml b/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml new file mode 100644 index 0000000..9e9dbff --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml @@ -0,0 +1,11 @@ +ldap_research_groups_sync.tab.config: + title: 'Configuration' + route_name: ldap_research_groups_sync.config + base_route: ldap_research_groups_sync.config + weight: 0 + +ldap_research_groups_sync.tab.access_rules: + title: 'Access Rules' + route_name: ldap_research_groups_sync.access_rules + base_route: ldap_research_groups_sync.config + weight: 10 diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.module b/ldap_research_groups_sync/ldap_research_groups_sync.module new file mode 100644 index 0000000..4bbf8a2 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.module @@ -0,0 +1,238 @@ +' . t('This module synchronizes research groups from an LDAP server to groups through cron.') . '

'; + } +} + +/** + * Implements hook_cron(). + */ +function ldap_research_groups_sync_cron() { + // Check if module is properly configured + $config = \Drupal::config('ldap_research_groups_sync.settings'); + $ldap_server_id = $config->get('ldap_server_id'); + $ldap_query_id = $config->get('ldap_query_id'); + + // Validate LDAP server + try { + $entity_type_manager = \Drupal::entityTypeManager(); + $server_storage = $entity_type_manager->getStorage('ldap_server'); + $server = $server_storage->load($ldap_server_id); + + if (!$server || !$server->get('status')) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: LDAP server "@server_id" not found or inactive. Configure at /admin/config/local-modules/ldap-research-groups-sync', [ + '@server_id' => $ldap_server_id, + ]); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: error checking LDAP server: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Validate LDAP query + try { + if ($entity_type_manager->hasDefinition('ldap_query_entity')) { + $query_storage = $entity_type_manager->getStorage('ldap_query_entity'); + $query = $query_storage->load($ldap_query_id); + + if (!$query || !$query->get('status')) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: LDAP query "@query_id" not found or inactive. Configure at /admin/config/local-modules/ldap-research-groups-sync', [ + '@query_id' => $ldap_query_id, + ]); + return; + } + } + else { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: ldap_query module not available.'); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: error checking LDAP query: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Validate group type + try { + $group_type_id = $config->get('group_type_id'); + $group_type_storage = $entity_type_manager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if (!$group_type) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: group type "@type_id" not found. Configure at /admin/config/local-modules/ldap-research-groups-sync', [ + '@type_id' => $group_type_id, + ]); + return; + } + } + catch (\Exception $e) { + \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: error checking group type: @message', [ + '@message' => $e->getMessage(), + ]); + return; + } + + // Get LDAP synchronization service + $ldap_sync = \Drupal::service('ldap_research_groups_sync.sync'); + + // Execute research groups synchronization + try { + $ldap_sync->syncResearchGroups(); + \Drupal::logger('ldap_research_groups_sync')->info('Research groups synchronization executed successfully.'); + } + catch (\Exception $e) { + \Drupal::logger('ldap_research_groups_sync')->error('Error in research groups synchronization: @message', [ + '@message' => $e->getMessage(), + ]); + } +} + +/** + * Implements hook_entity_access(). + * + * Evaluates access rules configured in the module settings for view, update + * and delete operations on existing entities. + */ +function ldap_research_groups_sync_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { + // Skip group-related entities to avoid conflicts with the group module. + $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type']; + if (in_array($entity->getEntityTypeId(), $skip_types, TRUE)) { + return AccessResult::neutral(); + } + + return \Drupal::service('ldap_research_groups_sync.access_rules') + ->checkAccess($entity, $operation, $account); +} + +/** + * Implements hook_entity_create_access(). + * + * Evaluates access rules configured in the module settings for create + * operations on new entities. + */ +function ldap_research_groups_sync_entity_create_access(AccountInterface $account, array $context, $entity_bundle) { + $entity_type_id = $context['entity_type_id'] ?? ''; + + // Skip group-related entities. + $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type']; + if (in_array($entity_type_id, $skip_types, TRUE)) { + return AccessResult::neutral(); + } + + return \Drupal::service('ldap_research_groups_sync.access_rules') + ->checkCreateAccess($account, $entity_type_id, $entity_bundle ?? ''); +} + +/** + * Implements hook_entity_field_access(). + * + * Denies edit access to fields managed by the LDAP sync in forms and APIs + * (REST, JSON:API). Programmatic saves are not affected. + * + * Protected user fields: + * - field_user_research_groups: multi-value reference to the research groups + * the user belongs to, populated by our sync. Protecting this field + * prevents the validation error "This entity cannot be referenced" that + * occurs when the widget tries to validate the referenced group against + * the current user's access. + */ +function ldap_research_groups_sync_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) { + $protected_user_fields = [ + 'field_user_research_groups', + ]; + + if ( + $field_definition->getTargetEntityTypeId() === 'user' && + in_array($field_definition->getName(), $protected_user_fields, TRUE) && + $operation === 'edit' + ) { + // Sempre nega edição via UI/API, inclusive para o uid 1. + // Saves programáticos (ldap_user, drush) não passam por este hook. + return AccessResult::forbidden('This field is managed exclusively by LDAP synchronization.'); + } + return AccessResult::neutral(); +} + +/** + * Implements hook_user_presave(). + * + * Protects fields managed by the LDAP sync from unauthorized programmatic + * changes on existing users. Allows: + * - Saves during authorized LDAP sync (LdapResearchGroupsSync::$syncing TRUE) + * - New user creation (initial provisioning via ldap_user) + * - Saves by users with the 'edit ldap managed user rg fields' permission + */ +function ldap_research_groups_sync_user_presave(\Drupal\user\UserInterface $user) { + // Permite durante qualquer sync LDAP autorizado. + if (LdapResearchGroupsSync::isSyncing()) { + return; + } + + // Permite na criação inicial do usuário (primeiro login LDAP). + if ($user->isNew()) { + return; + } + + // Permite se o usuário atual tem a permissão de bypass. + if (\Drupal::currentUser()->hasPermission('edit ldap managed user rg fields')) { + return; + } + + $original = $user->original; + if ($original === NULL) { + return; + } + + // Fields managed by LDAP that cannot be changed externally. + $protected_fields = [ + 'field_user_research_groups', + ]; + + foreach ($protected_fields as $field_name) { + if (!$user->hasField($field_name)) { + continue; + } + + $original_value = $original->get($field_name)->getValue(); + $new_value = $user->get($field_name)->getValue(); + + if ($original_value !== $new_value) { + $user->set($field_name, $original_value); + \Drupal::logger('ldap_research_groups_sync')->warning( + 'Unauthorized attempt to change @field on user @username was blocked. Use LDAP synchronization to update this field.', + [ + '@field' => $field_name, + '@username' => $user->getAccountName(), + ] + ); + } + } +} + diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml b/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml new file mode 100644 index 0000000..deb35fb --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml @@ -0,0 +1,9 @@ +administer ldap research groups sync: + title: 'Administer LDAP Research Groups Sync' + description: 'Configure the LDAP research groups synchronization.' + restrict access: true + +edit ldap managed user rg fields: + title: 'Edit LDAP-managed research group fields on users' + description: 'Allows manually editing fields such as field_user_research_groups that are normally managed by LDAP synchronization. Use only in emergencies.' + restrict access: true diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml b/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml new file mode 100644 index 0000000..a4be2e7 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml @@ -0,0 +1,25 @@ +ldap_research_groups_sync.config: + path: '/admin/config/local-modules/ldap-research-groups-sync' + defaults: + _form: '\Drupal\ldap_research_groups_sync\Form\LdapResearchGroupsSyncConfigForm' + _title: 'LDAP Research Groups Sync' + requirements: + _permission: 'administer ldap research groups sync' + +ldap_research_groups_sync.access_rules: + path: '/admin/config/local-modules/ldap-research-groups-sync/access-rules' + defaults: + _form: '\Drupal\ldap_research_groups_sync\Form\AccessRulesForm' + _title: 'Access Rules' + requirements: + _permission: 'administer ldap research groups sync' + +ldap_research_groups_sync.access_rule_form: + path: '/admin/config/local-modules/ldap-research-groups-sync/access-rule/{rule_index}' + defaults: + _form: '\Drupal\ldap_research_groups_sync\Form\AccessRuleForm' + _title: 'Access Rule' + rule_index: 'new' + requirements: + _permission: 'administer ldap research groups sync' + rule_index: 'new|\d+' diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.services.yml b/ldap_research_groups_sync/ldap_research_groups_sync.services.yml new file mode 100644 index 0000000..2ae2c70 --- /dev/null +++ b/ldap_research_groups_sync/ldap_research_groups_sync.services.yml @@ -0,0 +1,8 @@ +services: + ldap_research_groups_sync.sync: + class: Drupal\ldap_research_groups_sync\LdapResearchGroupsSync + arguments: ['@entity_type.manager', '@config.factory', '@logger.factory', '@ldap.bridge'] + + ldap_research_groups_sync.access_rules: + class: Drupal\ldap_groups_sync\GroupAccessRulesService + arguments: ['@config.factory', '@entity_type.manager', 'ldap_research_groups_sync.settings'] diff --git a/ldap_research_groups_sync/scripts/install_field_rg_department.php b/ldap_research_groups_sync/scripts/install_field_rg_department.php new file mode 100644 index 0000000..edca816 --- /dev/null +++ b/ldap_research_groups_sync/scripts/install_field_rg_department.php @@ -0,0 +1,85 @@ +getPath('ldap_research_groups_sync'); +$config_path = $module_path . '/config/optional'; + +$config_factory = \Drupal::configFactory(); +$yaml_parser = \Drupal::service('serialization.yaml'); + +$configs_to_install = [ + 'field.storage.group.field_rg_department', + 'field.field.group.research_group.field_rg_department', + 'core.entity_form_display.group.research_group.default', + 'core.entity_view_display.group.research_group.default', +]; + +$imported = 0; +$skipped = 0; + +foreach ($configs_to_install as $config_name) { + $file = $config_path . '/' . $config_name . '.yml'; + + if (!file_exists($file)) { + echo "ERROR: File not found: $file\n"; + continue; + } + + $existing = $config_factory->get($config_name); + + // For display configs, always update (they already exist and need the new field). + $is_display = str_starts_with($config_name, 'core.entity_'); + + if (!$existing->isNew() && !$is_display) { + echo "SKIP: $config_name (already exists)\n"; + $skipped++; + continue; + } + + $data = $yaml_parser->decode(file_get_contents($file)); + $config_factory->getEditable($config_name)->setData($data)->save(); + + echo "OK: $config_name\n"; + $imported++; +} + +if ($imported > 0) { + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + \Drupal::service('plugin.manager.field.widget')->clearCachedDefinitions(); + \Drupal::service('plugin.manager.field.formatter')->clearCachedDefinitions(); + \Drupal::service('config.factory')->clearStaticCache(); + + // Create the database table for the new field storage. + $field_manager = \Drupal::service('entity_field.manager'); + $field_manager->clearCachedFieldDefinitions(); + $update_manager = \Drupal::entityDefinitionUpdateManager(); + $storage_defs = $field_manager->getFieldStorageDefinitions('group'); + if (isset($storage_defs['field_rg_department'])) { + $update_manager->installFieldStorageDefinition( + 'field_rg_department', + 'group', + 'ldap_research_groups_sync', + $storage_defs['field_rg_department'] + ); + echo "Schema update applied (group__field_rg_department table created).\n"; + } + else { + echo "WARNING: field_rg_department storage definition not found — table not created.\n"; + } + + \Drupal::service('router.builder')->rebuild(); + + echo "\nDone. Imported: $imported, Skipped: $skipped\n"; + echo "Run 'drush cr' to clear all caches.\n"; +} +else { + echo "\nNothing imported. Skipped: $skipped\n"; +} diff --git a/ldap_research_groups_sync/src/Form/AccessRuleForm.php b/ldap_research_groups_sync/src/Form/AccessRuleForm.php new file mode 100644 index 0000000..abaa3dc --- /dev/null +++ b/ldap_research_groups_sync/src/Form/AccessRuleForm.php @@ -0,0 +1,40 @@ +entityTypeManager = $entity_type_manager; + $this->ldapResearchGroupsSync = $ldap_research_groups_sync; + $this->messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('ldap_research_groups_sync.sync'), + $container->get('messenger') + ); + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['ldap_research_groups_sync.settings']; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'ldap_research_groups_sync_config_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config('ldap_research_groups_sync.settings'); + + // Group Type + $form['group_type'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Group Type'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $group_types = $this->getGroupTypes(); + + $form['group_type']['group_type_id'] = [ + '#type' => 'select', + '#title' => $this->t('Target Group Type'), + '#description' => $this->t('Select the group type where research groups will be synchronized.
Required fields: field_rg_code, field_rg_phone, field_rg_mail'), + '#options' => $group_types, + '#empty_option' => $this->t('- Select a group type -'), + '#default_value' => $config->get('group_type_id') ?: 'research_group', + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateGroupTypeActions', + 'wrapper' => 'group-type-wrapper', + 'effect' => 'fade', + 'disable-refocus' => TRUE, + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ], + ]; + + $form['group_type']['group_type_actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions', 'group-type-actions']], + '#prefix' => '
', + '#suffix' => '
', + ]; + + $research_group_exists = isset($group_types['research_group']); + + if (empty($group_types) || !$research_group_exists) { + $form['group_type']['setup_notice'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('The default "research_group" group type is not installed.') . + '
', + '#weight' => -10, + ]; + + $form['group_type']['group_type_actions']['install_defaults'] = [ + '#type' => 'submit', + '#value' => $this->t('Install Default Configuration'), + '#submit' => ['::installDefaultConfiguration'], + '#button_type' => 'primary', + '#limit_validation_errors' => [], + '#attributes' => ['class' => ['button', 'button--primary']], + ]; + + $form['group_type']['install_help'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('The button above will create:
    +
  • Group type "research_group" with all required fields
  • +
  • User fields for research group synchronization
  • +
  • Default group roles and displays
  • +
Or you can create a custom group type manually.', [ + '@url' => '/admin/group/types/add', + ]) . '
', + '#weight' => 100, + ]; + } + + $form['group_type']['group_type_actions']['create_group_type'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Create New Group Type') . ' ', + ]; + + $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'research_group'; + if (!empty($selected_group_type)) { + $form['group_type']['group_type_actions']['edit_group_type'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Manage Group Type') . ' ', + ]; + + $form['group_type']['group_type_actions']['edit_group_type_fields'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Manage Fields') . '', + ]; + } + + // LDAP Server + $form['ldap_server'] = [ + '#type' => 'fieldset', + '#title' => $this->t('LDAP Server'), + '#collapsible' => FALSE, + ]; + + $ldap_servers = $this->getLdapServers(); + + $form['ldap_server']['ldap_server_id'] = [ + '#type' => 'select', + '#title' => $this->t('LDAP Server'), + '#description' => $this->t('Select the LDAP server configured for research groups synchronization.'), + '#options' => $ldap_servers, + '#empty_option' => $this->t('- Select a server -'), + '#default_value' => $config->get('ldap_server_id'), + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateLdapQueries', + 'wrapper' => 'ldap-queries-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ], + ]; + + if (empty($ldap_servers)) { + $form['ldap_server']['no_servers'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('No LDAP server found. Configure an LDAP server first.', [ + '@url' => '/admin/config/people/ldap/servers', + ]) . '
', + ]; + } + + // LDAP Query + $form['ldap_query'] = [ + '#type' => 'fieldset', + '#title' => $this->t('LDAP Query'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id'); + $ldap_queries = $this->getLdapQueriesForServer($selected_server); + + $form['ldap_query']['ldap_query_id'] = [ + '#type' => 'select', + '#title' => $this->t('LDAP Query'), + '#description' => $this->t('Select the LDAP query that will be used to search for research groups.'), + '#options' => $ldap_queries, + '#empty_option' => $this->t('- Select a query -'), + '#default_value' => $config->get('ldap_query_id'), + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateLdapQueries', + 'wrapper' => 'ldap-queries-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ['ldap_query_id'], + ], + ]; + + if (empty($ldap_queries) && !empty($selected_server)) { + $form['ldap_query']['no_queries'] = [ + '#type' => 'markup', + '#markup' => '
' . + $this->t('No LDAP query found for the selected server. Create a new LDAP query.', [ + '@url' => '/admin/config/people/ldap/query/add', + ]) . '
', + ]; + } + + if (!empty($selected_server)) { + $form['ldap_query']['query_actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions', 'query-actions']], + '#prefix' => '
', + '#suffix' => '
', + ]; + + $form['ldap_query']['query_actions']['create_query'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Create New LDAP Query') . ' ', + ]; + + $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id'); + if (!empty($selected_query)) { + $form['ldap_query']['query_actions']['edit_query'] = [ + '#type' => 'markup', + '#markup' => '' . + $this->t('Edit Selected Query') . '', + ]; + } + } + + // Dynamic attribute mapping + $form['attribute_mapping'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Attribute Mapping'), + '#description' => $this->t('Configure how LDAP attributes are mapped to target entity fields.
Note: For "User Reference" mappings, the search attribute will be automatically obtained from the "Account name attribute" field configured in the selected LDAP server.'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'research_group'; + $entity_fields = $this->getGroupTypeFields($selected_group_type); + + $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id'); + $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id'); + $ldap_attributes = $this->getQueryAttributes($selected_server, $selected_query); + + $mappings_reset = $form_state->get('mappings_reset'); + $form_state_mappings = $form_state->getValue('mappings'); + + if ($mappings_reset && !empty($form_state_mappings)) { + $existing_mappings = $form_state_mappings; + } + elseif (!empty($form_state_mappings)) { + $existing_mappings = []; + $valid_fields = array_keys($entity_fields); + + foreach ($form_state_mappings as $mapping) { + if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) { + $existing_mappings[] = $mapping; + } + } + + if (empty($existing_mappings)) { + $existing_mappings = $this->getDefaultMappings(); + } + } + else { + $config_mappings = $config->get('attribute_mappings'); + + if (!empty($config_mappings)) { + $valid_mappings = []; + $valid_fields = array_keys($entity_fields); + + foreach ($config_mappings as $mapping) { + if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) { + $valid_mappings[] = $mapping; + } + } + + $existing_mappings = !empty($valid_mappings) ? $valid_mappings : $this->getDefaultMappings(); + } + else { + $existing_mappings = $this->getDefaultMappings(); + } + } + + $form['attribute_mapping']['mappings'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Entity Field'), + $this->t('LDAP Attribute'), + $this->t('Mapping Type'), + $this->t('Remove'), + ], + '#empty' => $this->t('No mappings configured.'), + ]; + + $mapping_count = $form_state->get('mapping_count') ?: count($existing_mappings); + $form_state->set('mapping_count', $mapping_count); + + for ($i = 0; $i < $mapping_count; $i++) { + $mapping = isset($existing_mappings[$i]) ? $existing_mappings[$i] : [ + 'field' => '', + 'attribute' => '', + 'mapping_type' => 'simple', + ]; + + $field_default = isset($mapping['field']) ? $mapping['field'] : ''; + if (!empty($field_default) && !isset($entity_fields[$field_default])) { + $field_default = ''; + } + + $attribute_default = isset($mapping['attribute']) ? $mapping['attribute'] : ''; + if (!empty($attribute_default) && !isset($ldap_attributes[$attribute_default])) { + $attribute_default = ''; + } + + $field_element = [ + '#type' => 'select', + '#options' => $entity_fields, + '#empty_option' => $this->t('- Select a field -'), + '#default_value' => $field_default, + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 200px; width: auto;', + ], + ]; + + if ($mappings_reset && !empty($field_default)) { + $field_element['#value'] = $field_default; + } + + $form['attribute_mapping']['mappings'][$i]['field'] = $field_element; + + $form['attribute_mapping']['mappings'][$i]['attribute'] = [ + '#type' => 'select', + '#options' => $ldap_attributes, + '#empty_option' => $this->t('- Select an attribute -'), + '#default_value' => $attribute_default, + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 180px; width: auto;', + ], + ]; + + $form['attribute_mapping']['mappings'][$i]['mapping_type'] = [ + '#type' => 'select', + '#options' => [ + 'simple' => $this->t('Simple (text)'), + 'user_reference' => $this->t('User Reference (DN → User)'), + 'department_reference' => $this->t('Department Reference (code → Department)'), + ], + '#default_value' => $mapping['mapping_type'] ?? 'simple', + '#required' => FALSE, + '#attributes' => [ + 'style' => 'max-width: 150px; width: auto;', + ], + ]; + + $form['attribute_mapping']['mappings'][$i]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + if ($mappings_reset) { + $form_state->set('mappings_reset', FALSE); + } + + $form['attribute_mapping']['actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions']], + ]; + + $form['attribute_mapping']['actions']['add_mapping'] = [ + '#type' => 'submit', + '#value' => $this->t('Add Mapping'), + '#submit' => ['::addMapping'], + '#ajax' => [ + 'callback' => '::updateAttributeMappings', + 'wrapper' => 'attribute-mapping-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + $form['attribute_mapping']['actions']['remove_selected'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove Selected'), + '#submit' => ['::removeSelectedMappings'], + '#ajax' => [ + 'callback' => '::updateAttributeMappings', + 'wrapper' => 'attribute-mapping-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + // Member synchronization from LDAP attribute + $form['member_sync'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Member Synchronization'), + ]; + + $form['member_sync']['member_sync_enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable member synchronization from LDAP attribute'), + '#description' => $this->t('When enabled, group memberships are managed based on the LDAP member attribute of each group entry. Members are added or removed to match the LDAP list.'), + '#default_value' => $config->get('member_sync_enabled') ?? TRUE, + ]; + + $form['member_sync']['member_attribute'] = [ + '#type' => 'textfield', + '#title' => $this->t('Member LDAP attribute'), + '#description' => $this->t('Name of the LDAP attribute on each group entry that lists its members (as DNs or UIDs). Common values: member, uniqueMember, memberUid. The attribute must be included in the LDAP query.'), + '#default_value' => $config->get('member_attribute') ?: 'member', + '#size' => 40, + ]; + + // Role Mapping + $form['role_mapping'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Group Role Mapping'), + '#description' => $this->t('Configure how user attributes map to group roles. Each role can have its own mapping criteria.'), + '#collapsible' => FALSE, + '#prefix' => '
', + '#suffix' => '
', + ]; + + $form['role_mapping']['role_mapping_enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable role mapping'), + '#description' => $this->t('When enabled, users will be assigned group roles based on the mappings below.'), + '#default_value' => $config->get('role_mapping_enabled') ?: FALSE, + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [ + ['group_type_id'], + ['ldap_server_id'], + ['ldap_query_id'], + ['role_mapping_enabled'], + ], + ]; + + $form['role_mapping']['role_mapping_fields'] = [ + '#type' => 'container', + '#prefix' => '
', + '#suffix' => '
', + ]; + + $role_mapping_enabled_value = $form_state->getValue('role_mapping_enabled'); + if ($role_mapping_enabled_value !== NULL) { + $role_mapping_enabled = (bool) $role_mapping_enabled_value; + } + else { + $role_mapping_enabled = $config->get('role_mapping_enabled') ?: FALSE; + } + + if ($role_mapping_enabled) { + $group_roles = $this->getGroupRoles($selected_group_type); + $role_ldap_attributes = $ldap_attributes ?? []; + $user_fields = $this->getUserFields(); + + $role_form_state_mappings = $form_state->getValue('role_mappings'); + if (!empty($role_form_state_mappings)) { + $existing_role_mappings = $role_form_state_mappings; + } + else { + $config_role_mappings = $config->get('role_mappings'); + $existing_role_mappings = $config_role_mappings ?: []; + } + + if (empty($existing_role_mappings)) { + $existing_role_mappings = []; + } + + $form['role_mapping']['role_mapping_fields']['role_mappings'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Group Role'), + $this->t('Source'), + $this->t('User Field / LDAP Attribute'), + $this->t('Group Field / Values'), + $this->t('Remove'), + ], + '#empty' => $this->t('No role mappings configured. Users will be assigned the default role.'), + '#attributes' => ['class' => ['role-mappings-table']], + '#attached' => [ + 'library' => ['ldap_research_groups_sync/role_mapping_styles'], + ], + ]; + + $role_mapping_count = $form_state->get('role_mapping_count'); + if ($role_mapping_count === NULL) { + $role_mapping_count = count($existing_role_mappings); + } + $form_state->set('role_mapping_count', $role_mapping_count); + + $group_fields = $this->getGroupTypeFields($selected_group_type); + + for ($i = 0; $i < $role_mapping_count; $i++) { + $mapping = isset($existing_role_mappings[$i]) ? $existing_role_mappings[$i] : [ + 'group_role' => '', + 'source' => 'user_field', + 'source_field' => '', + 'group_field' => '', + 'values' => '', + ]; + + $current_source = $form_state->getValue(['role_mappings', $i, 'source']) ?? $mapping['source'] ?? 'user_field'; + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['group_role'] = [ + '#type' => 'select', + '#options' => $group_roles, + '#empty_option' => $this->t('- Select a role -'), + '#default_value' => $mapping['group_role'], + '#required' => FALSE, + ]; + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source'] = [ + '#type' => 'select', + '#options' => [ + 'user_field' => $this->t('User Field'), + 'ldap_attribute' => $this->t('LDAP Attribute'), + 'group_field_match' => $this->t('Group Field Match'), + ], + '#default_value' => $current_source, + '#required' => FALSE, + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + ]; + + if ($current_source === 'group_field_match') { + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [ + '#type' => 'select', + '#options' => $user_fields, + '#empty_option' => $this->t('- Select user field -'), + '#default_value' => $mapping['source_field'], + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } + else { + if ($current_source === 'ldap_attribute') { + $source_options = $role_ldap_attributes; + } + else { + $source_options = $user_fields; + } + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [ + '#type' => 'select', + '#options' => $source_options, + '#empty_option' => $this->t('- Select attribute/field -'), + '#default_value' => $mapping['source_field'], + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } + + if ($current_source === 'group_field_match') { + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [ + '#type' => 'select', + '#options' => $group_fields, + '#empty_option' => $this->t('- Select group field -'), + '#default_value' => $mapping['group_field'] ?? '', + '#required' => FALSE, + '#attributes' => ['class' => ['role-mapping-field-select']], + ]; + } + else { + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [ + '#type' => 'textarea', + '#default_value' => is_array($mapping['values']) ? implode(', ', $mapping['values']) : $mapping['values'], + '#placeholder' => $this->t('e.g., Director, Manager'), + '#rows' => 2, + '#resizable' => 'vertical', + '#attributes' => ['class' => ['role-mapping-values-textarea']], + ]; + } + + $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + $form['role_mapping']['role_mapping_fields']['actions'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['form-actions']], + ]; + + $form['role_mapping']['role_mapping_fields']['actions']['add_role_mapping'] = [ + '#type' => 'submit', + '#value' => $this->t('Add Role Mapping'), + '#submit' => ['::addRoleMapping'], + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + + $form['role_mapping']['role_mapping_fields']['actions']['remove_selected_roles'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove Selected'), + '#submit' => ['::removeSelectedRoleMappings'], + '#ajax' => [ + 'callback' => '::updateRoleMappingFields', + 'wrapper' => 'role-mapping-fields-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [], + ]; + } + + // Actions + $form['actions'] = [ + '#type' => 'actions', + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save Configuration'), + '#button_type' => 'primary', + ]; + + $form['actions']['test_connection'] = [ + '#type' => 'submit', + '#value' => $this->t('Test Connection'), + '#submit' => ['::testConnection'], + '#limit_validation_errors' => [['ldap_server_id']], + ]; + + $form['actions']['sync_research_groups'] = [ + '#type' => 'submit', + '#value' => $this->t('Synchronize Research Groups'), + '#submit' => ['::syncResearchGroups'], + '#button_type' => 'danger', + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * Gets list of available LDAP servers. + */ + protected function getLdapServers() { + $servers = []; + + try { + $server_storage = $this->entityTypeManager->getStorage('ldap_server'); + $server_entities = $server_storage->loadByProperties(['status' => TRUE]); + + foreach ($server_entities as $server) { + $servers[$server->id()] = $server->label() . ' (' . $server->get('address') . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading LDAP servers: @message', ['@message' => $e->getMessage()])); + } + + return $servers; + } + + /** + * Gets attributes from the selected LDAP query. + */ + protected function getQueryAttributes($server_id, $query_id) { + $attributes = []; + + if (empty($query_id) || !$this->entityTypeManager->hasDefinition('ldap_query_entity')) { + return [ + 'cn' => 'cn', + 'description' => 'description', + 'mail' => 'mail', + 'telephoneNumber' => 'telephoneNumber', + ]; + } + + try { + $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity'); + $query_entity = $query_storage->load($query_id); + + if ($query_entity) { + $processed_attributes = $query_entity->getProcessedAttributes(); + + if (!empty($processed_attributes)) { + foreach ($processed_attributes as $attribute) { + $attributes[$attribute] = $attribute; + } + } + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading query attributes: @message', ['@message' => $e->getMessage()])); + } + + if (empty($attributes)) { + $attributes = [ + 'cn' => 'cn', + 'description' => 'description', + 'mail' => 'mail', + 'telephoneNumber' => 'telephoneNumber', + ]; + } + + return $attributes; + } + + /** + * Returns default mappings for research groups. + */ + protected function getDefaultMappings() { + return [ + ['field' => 'label', 'attribute' => 'description', 'mapping_type' => 'simple'], + ['field' => 'field_rg_code', 'attribute' => 'cn', 'mapping_type' => 'simple'], + ['field' => 'field_rg_mail', 'attribute' => 'mail', 'mapping_type' => 'simple'], + ['field' => 'field_rg_phone', 'attribute' => 'telephoneNumber', 'mapping_type' => 'simple'], + ]; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + $mapping_values = $form_state->getValue('mappings'); + if (!empty($mapping_values)) { + foreach ($mapping_values as $index => $mapping_data) { + if (!empty($mapping_data['remove'])) { + continue; + } + + $has_field = !empty($mapping_data['field']); + $has_attribute = !empty($mapping_data['attribute']); + + if ($has_field && !$has_attribute) { + $form_state->setErrorByName("mappings][$index][attribute", + $this->t('LDAP attribute is required when field is selected.')); + } + elseif (!$has_field && $has_attribute) { + $form_state->setErrorByName("mappings][$index][field", + $this->t('Vocabulary field is required when attribute is selected.')); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + parent::submitForm($form, $form_state); + + $config = $this->config('ldap_research_groups_sync.settings'); + + $config->set('ldap_server_id', $form_state->getValue('ldap_server_id')) + ->set('ldap_query_id', $form_state->getValue('ldap_query_id')) + ->set('group_type_id', $form_state->getValue('group_type_id')) + ->set('member_sync_enabled', $form_state->getValue('member_sync_enabled')) + ->set('member_attribute', $form_state->getValue('member_attribute')); + + $mappings = []; + $mapping_values = $form_state->getValue('mappings'); + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + if (!empty($mapping_data['remove'])) { + continue; + } + + if (empty($mapping_data['field']) && empty($mapping_data['attribute'])) { + continue; + } + + $mappings[] = [ + 'field' => $mapping_data['field'], + 'attribute' => $mapping_data['attribute'], + 'mapping_type' => $mapping_data['mapping_type'] ?? 'simple', + ]; + } + } + + $config->set('attribute_mappings', $mappings); + + $role_mappings = []; + $role_mapping_values = $form_state->getValue('role_mappings'); + + if (!empty($role_mapping_values)) { + foreach ($role_mapping_values as $role_mapping_data) { + if (!empty($role_mapping_data['remove'])) { + continue; + } + + if (empty($role_mapping_data['group_role']) && empty($role_mapping_data['source_field'])) { + continue; + } + + $source = $role_mapping_data['source']; + + if ($source === 'group_field_match') { + $role_mapping = [ + 'group_role' => $role_mapping_data['group_role'], + 'source' => $source, + 'source_field' => $role_mapping_data['source_field'], + 'group_field' => $role_mapping_data['values'], + 'values' => [], + ]; + } + else { + $values = $role_mapping_data['values']; + if (is_string($values)) { + $values = array_map('trim', explode(',', $values)); + $values = array_filter($values); + } + + $role_mapping = [ + 'group_role' => $role_mapping_data['group_role'], + 'source' => $source, + 'source_field' => $role_mapping_data['source_field'], + 'group_field' => '', + 'values' => $values, + ]; + } + + $role_mappings[] = $role_mapping; + } + } + + $config->set('role_mapping_enabled', $form_state->getValue('role_mapping_enabled')) + ->set('role_mappings', $role_mappings) + ->save(); + + $this->messenger()->addStatus($this->t('Configuration saved successfully.')); + } + + /** + * Submit handler to test LDAP connection. + */ + public function testConnection(array &$form, FormStateInterface $form_state) { + $server_id = $form_state->getValue('ldap_server_id'); + + if (empty($server_id)) { + $this->messenger->addError($this->t('Please select an LDAP server to test.')); + return; + } + + try { + $server_storage = $this->entityTypeManager->getStorage('ldap_server'); + $server = $server_storage->load($server_id); + + if ($server && $server->get('status')) { + $this->messenger->addStatus($this->t('LDAP server "@server" is configured and active.', [ + '@server' => $server->label(), + ])); + } + else { + $this->messenger->addError($this->t('LDAP server not found or inactive.')); + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error testing connection: @message', ['@message' => $e->getMessage()])); + } + } + + /** + * Submit handler para sincronizar grupos de pesquisa. + */ + public function syncResearchGroups(array &$form, FormStateInterface $form_state) { + if ($form_state->getErrors()) { + $this->messenger->addError($this->t('Please fix the errors in the form before running the synchronization.')); + return; + } + + try { + $this->ldapResearchGroupsSync->syncResearchGroups(); + $this->messenger->addStatus($this->t('Synchronization completed successfully. Check the logs for details.')); + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error during synchronization: @message', ['@message' => $e->getMessage()])); + } + } + + /** + * Callback AJAX para atualizar lista de queries quando servidor muda. + */ + public function updateLdapQueries(array &$form, FormStateInterface $form_state) { + return $form['ldap_query']; + } + + /** + * Submit handler para adicionar um novo mapeamento. + */ + public function addMapping(array &$form, FormStateInterface $form_state) { + $current_mappings = $form_state->getValue('mappings'); + + if (empty($current_mappings)) { + $config = $this->config('ldap_research_groups_sync.settings'); + $current_mappings = $config->get('attribute_mappings') ?: $this->getDefaultMappings(); + } + + $processed_mappings = []; + foreach ($current_mappings as $mapping) { + $processed_mappings[] = [ + 'field' => $mapping['field'] ?? '', + 'attribute' => $mapping['attribute'] ?? '', + 'mapping_type' => $mapping['mapping_type'] ?? 'simple', + 'remove' => FALSE, + ]; + } + + $processed_mappings[] = [ + 'field' => '', + 'attribute' => '', + 'mapping_type' => 'simple', + 'remove' => FALSE, + ]; + + $form_state->setValue('mappings', $processed_mappings); + $form_state->set('mapping_count', count($processed_mappings)); + $form_state->setRebuild(); + } + + /** + * Submit handler para remover mapeamentos selecionados. + */ + public function removeSelectedMappings(array &$form, FormStateInterface $form_state) { + $mapping_values = $form_state->getValue('mappings'); + $new_mappings = []; + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + if (empty($mapping_data['remove'])) { + $new_mappings[] = $mapping_data; + } + } + } + + $form_state->setValue('mappings', $new_mappings); + $form_state->set('mapping_count', count($new_mappings)); + $form_state->setRebuild(); + } + + /** + * Callback AJAX para atualizar mapeamentos de atributos. + */ + public function updateAttributeMappings(array &$form, FormStateInterface $form_state) { + return $form['attribute_mapping']; + } + + /** + * Submit handler to add a new role mapping. + */ + public function addRoleMapping(array &$form, FormStateInterface $form_state) { + $current_mappings = $form_state->getValue('role_mappings'); + + if (empty($current_mappings)) { + $config = $this->config('ldap_research_groups_sync.settings'); + $current_mappings = $config->get('role_mappings') ?: []; + } + + $processed_mappings = []; + foreach ($current_mappings as $mapping) { + $processed_mappings[] = [ + 'group_role' => $mapping['group_role'] ?? '', + 'source' => $mapping['source'] ?? 'ldap_attribute', + 'source_field' => $mapping['source_field'] ?? '', + 'values' => $mapping['values'] ?? '', + 'remove' => FALSE, + ]; + } + + $processed_mappings[] = [ + 'group_role' => '', + 'source' => 'ldap_attribute', + 'source_field' => '', + 'values' => '', + 'remove' => FALSE, + ]; + + $form_state->setValue('role_mappings', $processed_mappings); + $form_state->set('role_mapping_count', count($processed_mappings)); + $form_state->setRebuild(); + } + + /** + * Submit handler to remove selected role mappings. + */ + public function removeSelectedRoleMappings(array &$form, FormStateInterface $form_state) { + $mapping_values = $form_state->getValue('role_mappings'); + $new_mappings = []; + + if (!empty($mapping_values)) { + foreach ($mapping_values as $mapping_data) { + if (empty($mapping_data['remove'])) { + $new_mappings[] = $mapping_data; + } + } + } + + $form_state->setValue('role_mappings', $new_mappings); + $form_state->set('role_mapping_count', count($new_mappings)); + $form_state->setRebuild(); + } + + /** + * AJAX callback to update role mapping fields. + */ + public function updateRoleMappingFields(array &$form, FormStateInterface $form_state) { + return $form['role_mapping']['role_mapping_fields']; + } + + /** + * Gets available roles for the selected group type. + */ + protected function getGroupRoles($group_type_id) { + $roles = []; + + if (empty($group_type_id)) { + return $roles; + } + + try { + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if ($group_type) { + $group_role_storage = $this->entityTypeManager->getStorage('group_role'); + $group_roles = $group_role_storage->loadByProperties([ + 'group_type' => $group_type_id, + ]); + + foreach ($group_roles as $role) { + $role_id = $role->id(); + + if (strpos($role_id, '-anonymous') !== FALSE || strpos($role_id, '-outsider') !== FALSE) { + continue; + } + + $scope_label = ''; + + if (strpos($role_id, '_in') !== FALSE) { + $scope_label = ' (' . $this->t('Internal') . ')'; + } + elseif (strpos($role_id, '_out') !== FALSE) { + $scope_label = ' (' . $this->t('Outsider') . ')'; + } + + $roles[$role_id] = $role->label() . $scope_label; + } + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group roles: @message', ['@message' => $e->getMessage()])); + } + + return $roles; + } + + /** + * Gets available user fields for mapping. + */ + protected function getUserFields() { + $fields = []; + + try { + $field_manager = \Drupal::service('entity_field.manager'); + $field_definitions = $field_manager->getFieldDefinitions('user', 'user'); + + foreach ($field_definitions as $field_name => $field_definition) { + if (in_array($field_name, ['uid', 'uuid', 'langcode', 'name', 'pass', 'mail', 'timezone', 'status', 'created', 'changed', 'access', 'login', 'init', 'roles', 'default_langcode'])) { + continue; + } + + $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading user fields: @message', ['@message' => $e->getMessage()])); + } + + return $fields; + } + + /** + * Obtém lista de queries LDAP para um servidor específico. + */ + protected function getLdapQueriesForServer($server_id) { + $queries = []; + + if (empty($server_id)) { + return $queries; + } + + if (!$this->entityTypeManager->hasDefinition('ldap_query_entity')) { + $this->messenger->addWarning($this->t('The ldap_query module is not available. LDAP queries cannot be loaded.')); + return $queries; + } + + try { + $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity'); + $query_entities = $query_storage->loadByProperties([ + 'status' => TRUE, + 'server_id' => $server_id, + ]); + + foreach ($query_entities as $query) { + $queries[$query->id()] = $query->label() . ' (' . $query->get('base_dn') . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading LDAP queries: @message', ['@message' => $e->getMessage()])); + } + + return $queries; + } + + /** + * Gets list of available group types. + */ + protected function getGroupTypes() { + $group_types = []; + + try { + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type_entities = $group_type_storage->loadMultiple(); + + foreach ($group_type_entities as $group_type) { + $group_types[$group_type->id()] = $group_type->label() . ' (' . $group_type->id() . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group types: @message', ['@message' => $e->getMessage()])); + } + + return $group_types; + } + + /** + * Gets fields from the selected group type. + */ + protected function getGroupTypeFields($group_type_id) { + $fields = [ + 'label' => $this->t('Label (group title)'), + ]; + + if (empty($group_type_id)) { + return $fields; + } + + try { + $group_type_storage = $this->entityTypeManager->getStorage('group_type'); + $group_type = $group_type_storage->load($group_type_id); + + if (!$group_type) { + $this->messenger->addWarning($this->t('Group type "@type" not found. Please create it first or select a different group type.', [ + '@type' => $group_type_id, + ])); + return $fields; + } + + $field_manager = \Drupal::service('entity_field.manager'); + $field_definitions = $field_manager->getFieldDefinitions('group', $group_type_id); + + foreach ($field_definitions as $field_name => $field_definition) { + if (in_array($field_name, ['id', 'uuid', 'type', 'langcode', 'label', 'uid', 'created', 'changed', 'default_langcode'])) { + continue; + } + + $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')'; + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error loading group type fields: @message', ['@message' => $e->getMessage()])); + } + + return $fields; + } + + /** + * Callback AJAX to update group type actions when group type changes. + */ + public function updateGroupTypeActions(array &$form, FormStateInterface $form_state) { + return $form['group_type']; + } + + /** + * Submit handler to install default configuration. + */ + public function installDefaultConfiguration(array &$form, FormStateInterface $form_state) { + $module_path = \Drupal::service('extension.list.module')->getPath('ldap_research_groups_sync'); + $config_path = $module_path . '/config/optional'; + + if (!is_dir($config_path)) { + $this->messenger->addError($this->t('Configuration directory not found: @path', ['@path' => $config_path])); + return; + } + + try { + $config_factory = \Drupal::configFactory(); + $yaml_parser = \Drupal::service('serialization.yaml'); + $files_imported = 0; + + $import_order = [ + 'field.storage.*.yml', + 'group.type.*.yml', + 'group.role.*.yml', + 'field.field.*.yml', + 'core.entity_*.yml', + ]; + + foreach ($import_order as $pattern) { + $files = glob($config_path . '/' . $pattern); + foreach ($files as $file) { + $config_name = basename($file, '.yml'); + + $existing_config = $config_factory->get($config_name); + if (!$existing_config->isNew()) { + $this->messenger->addWarning($this->t('Configuration @name already exists, skipping.', ['@name' => $config_name])); + continue; + } + + $yaml_content = file_get_contents($file); + $data = $yaml_parser->decode($yaml_content); + + $config_factory->getEditable($config_name) + ->setData($data) + ->save(); + + $files_imported++; + } + } + + if ($files_imported > 0) { + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + \Drupal::service('plugin.manager.field.widget')->clearCachedDefinitions(); + \Drupal::service('plugin.manager.field.formatter')->clearCachedDefinitions(); + \Drupal::service('config.factory')->clearStaticCache(); + \Drupal::service('router.builder')->rebuild(); + + $this->messenger->addStatus($this->t('Successfully imported @count configuration files.', ['@count' => $files_imported])); + } + else { + $this->messenger->addWarning($this->t('No new configuration files were imported. All configurations already exist.')); + } + } + catch (\Exception $e) { + $this->messenger->addError($this->t('Error importing configuration: @message', ['@message' => $e->getMessage()])); + } + } + +} diff --git a/ldap_research_groups_sync/src/LdapResearchGroupsSync.php b/ldap_research_groups_sync/src/LdapResearchGroupsSync.php new file mode 100644 index 0000000..19f79a5 --- /dev/null +++ b/ldap_research_groups_sync/src/LdapResearchGroupsSync.php @@ -0,0 +1,1212 @@ +entityTypeManager = $entity_type_manager; + $this->configFactory = $config_factory; + $this->logger = $logger_factory->get('ldap_research_groups_sync'); + $this->ldapBridge = $ldap_bridge; + + // Check if entity type ldap_query_entity exists + if ($entity_type_manager->hasDefinition('ldap_query_entity')) { + $this->ldapQueryStorage = $entity_type_manager->getStorage('ldap_query_entity'); + } + else { + $this->logger->warning('Entity type ldap_query_entity not found. Check if ldap_query module is properly installed.'); + $this->ldapQueryStorage = NULL; + } + } + + /** + * Synchronizes research groups from LDAP to groups. + */ + public function syncResearchGroups() { + $this->logger->info('Starting research groups synchronization using configured LDAP query.'); + + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $bundle_id = $this->getBundleId(); + + $this->logger->info('Configuração atual - Server: @server, Query: @query, Group Type: @bundle', [ + '@server' => $config->get('ldap_server_id') ?: 'não configurado', + '@query' => $config->get('ldap_query_id') ?: 'não configurado', + '@bundle' => $bundle_id, + ]); + + try { + $research_groups = $this->fetchResearchGroupsUsingQuery(); + } + catch (\Exception $e) { + $this->logger->error('Erro durante fetchResearchGroupsUsingQuery: @message', ['@message' => $e->getMessage()]); + throw $e; + } + + if (empty($research_groups)) { + $this->logger->warning('Nenhum grupo de pesquisa encontrado no LDAP.'); + return; + } + + $this->logger->info('Iniciando sincronização de @count grupos de pesquisa', ['@count' => count($research_groups)]); + + $entity_storage = $this->getEntityStorage(); + $bundle_field = $this->getBundleField(); + $existing_entities = $entity_storage->loadByProperties([$bundle_field => $bundle_id]); + + // Cria um mapa de códigos existentes + $existing_codes = []; + foreach ($existing_entities as $entity) { + if ($entity->hasField('field_rg_code') && !$entity->get('field_rg_code')->isEmpty()) { + $code = $entity->get('field_rg_code')->value; + $existing_codes[$code] = $entity; + } + } + + $created = 0; + $updated = 0; + + foreach ($research_groups as $rg_data) { + $code = $rg_data['code']; + $name = $rg_data['name']; + $phone = $rg_data['phone']; + $mail = $rg_data['mail']; + + // Campos extras (incluindo referências de usuário) + $extra_fields = []; + foreach ($rg_data as $field => $value) { + if (!in_array($field, ['code', 'name', 'phone', 'mail', '_ldap_members'])) { + $extra_fields[$field] = $value; + } + } + + $this->logger->info('Processando grupo de pesquisa - Código: @code, Nome: @name', [ + '@code' => $code, + '@name' => $name, + ]); + + if (isset($existing_codes[$code])) { + // Atualiza entidade existente + $this->logger->info('Atualizando entidade existente com código: @code', ['@code' => $code]); + $entity = $existing_codes[$code]; + $name_field = $this->getNameField(); + + if ($name_field === 'label') { + $entity->set('label', $name); + } + else { + $entity->setName($name); + } + + if ($entity->hasField('field_rg_phone')) { + $entity->set('field_rg_phone', $phone); + } + if ($entity->hasField('field_rg_mail')) { + $entity->set('field_rg_mail', $mail); + } + + foreach ($extra_fields as $field => $value) { + if ($entity->hasField($field)) { + $entity->set($field, $value); + $this->logger->debug('Campo @field atualizado com valor @value', [ + '@field' => $field, + '@value' => $value ?? '', + ]); + } + else { + $this->logger->warning('Campo @field não existe na entidade', [ + '@field' => $field, + ]); + } + } + + try { + $entity->save(); + $updated++; + $this->logger->info('Entidade atualizada com sucesso: @code', ['@code' => $code]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao atualizar entidade @code: @error', [ + '@code' => $code, + '@error' => $e->getMessage(), + ]); + } + } + else { + // Cria nova entidade + $this->logger->info('Criando nova entidade com código: @code', ['@code' => $code]); + try { + $bundle_field = $this->getBundleField(); + $name_field = $this->getNameField(); + + $entity_data = [ + $bundle_field => $bundle_id, + $name_field => $name, + 'field_rg_code' => $code, + 'field_rg_phone' => $phone, + 'field_rg_mail' => $mail, + 'uid' => 1, + ]; + + foreach ($extra_fields as $field => $value) { + $entity_data[$field] = $value; + $this->logger->debug('Campo @field configurado com valor @value', [ + '@field' => $field, + '@value' => $value ?? '', + ]); + } + + $entity = $entity_storage->create($entity_data); + $entity->save(); + $created++; + $this->logger->info('Entidade criada com sucesso: @code (ID: @id)', [ + '@code' => $code, + '@id' => $entity->id(), + ]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao criar entidade @code: @error', [ + '@code' => $code, + '@error' => $e->getMessage(), + ]); + } + } + } + + if ($created > 0 || $updated > 0) { + $this->logger->info('Sincronização concluída com sucesso. Criados: @created, Atualizados: @updated', [ + '@created' => $created, + '@updated' => $updated, + ]); + } + else { + $this->logger->info('Nenhum grupo de pesquisa criado ou atualizado.'); + } + + // Sincroniza membros dos grupos via atributo LDAP + $this->syncMembersFromLdapAttribute($research_groups); + } + + /** + * Busca entidade de grupo de pesquisa pelo código. + * + * @param string $rg_code + * Código do grupo de pesquisa. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * Entidade grupo ou NULL se não encontrado. + */ + public function getResearchGroupByCode($rg_code) { + if (empty($rg_code)) { + return NULL; + } + + $bundle_id = $this->getBundleId(); + $bundle_field = $this->getBundleField(); + $entity_storage = $this->getEntityStorage(); + + $entities = $entity_storage->loadByProperties([ + $bundle_field => $bundle_id, + 'field_rg_code' => $rg_code, + ]); + + return !empty($entities) ? reset($entities) : NULL; + } + + /** + * Busca grupos de pesquisa usando a query LDAP configurada. + * + * @return array + * Array de grupos de pesquisa com código e descrição. + */ + protected function fetchResearchGroupsUsingQuery() { + $research_groups = []; + + $this->logger->info('Iniciando fetchResearchGroupsUsingQuery...'); + + if (!$this->ldapQueryStorage) { + $this->logger->error('Storage de queries LDAP não está disponível. Verifique se o módulo ldap_query está instalado.'); + return $research_groups; + } + + $this->logger->info('Storage de queries LDAP está disponível.'); + + try { + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $query_id = $config->get('ldap_query_id') ?: 'research_group_sync'; + + $this->logger->info('Tentando carregar query LDAP: @query_id', ['@query_id' => $query_id]); + + $query_entity = $this->ldapQueryStorage->load($query_id); + + if (!$query_entity) { + $this->logger->error('Query LDAP "@query_id" não encontrada. Configure a query via /admin/config/local-modules/ldap-research-groups-sync', [ + '@query_id' => $query_id, + ]); + return $research_groups; + } + + $this->logger->info('Query LDAP carregada com sucesso: @label', ['@label' => $query_entity->label()]); + + if (!$query_entity->get('status')) { + $this->logger->error('Query LDAP "@query_id" está desabilitada.', [ + '@query_id' => $query_id, + ]); + return $research_groups; + } + + $this->logger->info('Usando query LDAP configurada: @query_id (@label)', [ + '@query_id' => $query_id, + '@label' => $query_entity->label(), + ]); + + $this->logger->info('Executando query LDAP...'); + + try { + $server_id = $query_entity->getServerId(); + $base_dns = $query_entity->getProcessedBaseDns(); + $filter = $query_entity->getFilter(); + $attributes = $query_entity->getProcessedAttributes(); + + $this->logger->info('Parâmetros da query - Server: @server, Base DN: @base_dn, Filter: @filter, Attributes: @attrs', [ + '@server' => $server_id, + '@base_dn' => !empty($base_dns) ? implode(', ', $base_dns) : 'vazio', + '@filter' => $filter, + '@attrs' => !empty($attributes) ? implode(', ', $attributes) : 'vazio', + ]); + + $this->ldapBridge->setServerById($server_id); + + if (!$this->ldapBridge->bind()) { + throw new \Exception('Falha ao conectar ao servidor LDAP'); + } + + $this->logger->info('Conectado ao servidor LDAP com sucesso'); + + $all_results = []; + foreach ($base_dns as $base_dn) { + $this->logger->info('Executando busca em Base DN: @base_dn', ['@base_dn' => $base_dn]); + + try { + $ldap = $this->ldapBridge->get(); + $this->logger->info('Obtida instância LDAP do bridge'); + + // Garante que o atributo de membros está incluído na query + $member_sync_on = $config->get('member_sync_enabled') ?? TRUE; + if ($member_sync_on) { + $member_attr = $config->get('member_attribute') ?: 'member'; + if (!empty($attributes) && !in_array($member_attr, $attributes)) { + $attributes[] = $member_attr; + } + } + + $attr_options = []; + if (!empty($attributes)) { + $attr_options = ['filter' => $attributes]; + } + + $this->logger->info('Criando query Symfony LDAP...'); + $query = $ldap->query($base_dn, $filter, $attr_options); + + $this->logger->info('Executando query Symfony LDAP...'); + $base_results = $query->execute(); + + $this->logger->info('Query executada. Tipo de resultado: @type', ['@type' => gettype($base_results)]); + + if ($base_results instanceof \Traversable) { + $base_results = iterator_to_array($base_results); + } + + $this->logger->info('Resultados encontrados: @count', ['@count' => count($base_results)]); + + if (!empty($base_results) && is_array($base_results)) { + $all_results = array_merge($all_results, $base_results); + } + } + catch (\Exception $e) { + $this->logger->error('Erro na busca LDAP para Base DN @base_dn: @message', [ + '@base_dn' => $base_dn, + '@message' => $e->getMessage(), + ]); + } + } + + $results = $all_results; + $this->logger->info('Query LDAP executada com sucesso. Total de resultados: @count', ['@count' => count($results)]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]); + $this->logger->error('Stack trace: @trace', ['@trace' => $e->getTraceAsString()]); + throw $e; + } + + $this->logger->info('Verificando resultados da query...'); + + if (empty($results)) { + $this->logger->warning('Query LDAP não retornou resultados.'); + return $research_groups; + } + + $this->logger->info('Query LDAP retornou @count resultados', ['@count' => count($results)]); + + $research_groups = $this->processLdapResults($results); + + $this->logger->info('Processados @count grupos de pesquisa via query LDAP', ['@count' => count($research_groups)]); + } + catch (\Exception $e) { + $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]); + } + + return $research_groups; + } + + /** + * Sincroniza membros dos grupos baseado no código de grupo dos usuários. + */ + public function syncGroupMembers() { + $this->logger->info('Starting group membership synchronization.'); + + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $role_mapping_enabled = $config->get('role_mapping_enabled') ?? FALSE; + $role_mappings = $config->get('role_mappings') ?? []; + + if (!$role_mapping_enabled) { + $this->logger->info('Role mapping is disabled. Skipping group membership synchronization.'); + return; + } + + if (empty($role_mappings)) { + $this->logger->warning('Role mapping is enabled but no mappings are configured. Skipping synchronization.'); + return; + } + + $user_storage = $this->entityTypeManager->getStorage('user'); + $query = $user_storage->getQuery() + ->condition('status', 1) + ->condition('uid', 0, '>') + ->accessCheck(FALSE); + + $uids = $query->execute(); + + if (empty($uids)) { + $this->logger->warning('No active users found.'); + return; + } + + $users = $user_storage->loadMultiple($uids); + $this->logger->info('Processing @count users for group membership', ['@count' => count($users)]); + + $group_storage = $this->entityTypeManager->getStorage('group'); + $group_type_id = $this->getBundleId(); + $groups = $group_storage->loadByProperties(['type' => $group_type_id]); + + $this->logger->info('Found @count groups', ['@count' => count($groups)]); + + $added = 0; + $removed = 0; + $role_updated = 0; + $skipped = 0; + $already_member = 0; + $matched = 0; + + foreach ($users as $user) { + try { + $username = $user->getAccountName(); + $expected_memberships = []; + + foreach ($groups as $group) { + $group_role = $this->determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings); + + if ($group_role !== NULL) { + $expected_memberships[$group->id()] = $group_role; + } + } + + if (!empty($expected_memberships)) { + $matched++; + } + + foreach ($expected_memberships as $group_id => $expected_role) { + $group = $groups[$group_id]; + $membership = $group->getMember($user); + + if ($membership) { + $current_roles = $membership->getRoles(); + $current_role_ids = array_map(function ($role) { + return $role->id(); + }, $current_roles); + + if (!in_array($expected_role, $current_role_ids)) { + try { + foreach ($current_role_ids as $role_id) { + if ($role_id !== 'member' && $role_id !== $expected_role) { + $membership->removeRole($role_id); + } + } + if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) { + $membership->addRole($expected_role); + } + $membership->save(); + $role_updated++; + $this->logger->info('Updated role for user @username in group @group to @role', [ + '@username' => $username, + '@group' => $group->label(), + '@role' => $expected_role, + ]); + } + catch (\Exception $e) { + $this->logger->error('Failed to update role for user @username in group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + else { + $already_member++; + } + } + else { + try { + $values = []; + if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) { + $values['group_roles'] = [$expected_role]; + } + $group->addMember($user, $values); + $added++; + $this->logger->info('Added user @username to group @group with role @role', [ + '@username' => $username, + '@group' => $group->label(), + '@role' => $expected_role, + ]); + } + catch (\Exception $e) { + $this->logger->error('Failed to add user @username to group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + $skipped++; + } + } + } + + foreach ($groups as $group) { + if (!isset($expected_memberships[$group->id()])) { + $membership = $group->getMember($user); + if ($membership) { + try { + $group->removeMember($user); + $removed++; + $this->logger->info('Removed user @username from group @group', [ + '@username' => $username, + '@group' => $group->label(), + ]); + } + catch (\Exception $e) { + $this->logger->error('Failed to remove user @username from group @group: @error', [ + '@username' => $username, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + } + } + } + catch (\Exception $e) { + $this->logger->error('Error processing user @uid: @error', [ + '@uid' => $user->id(), + '@error' => $e->getMessage(), + ]); + $skipped++; + } + } + + $this->logger->info('Group membership synchronization completed. Matched: @matched, Added: @added, Already member: @already, Removed: @removed, Role updated: @role_updated, Skipped: @skipped', [ + '@matched' => $matched, + '@added' => $added, + '@already' => $already_member, + '@removed' => $removed, + '@role_updated' => $role_updated, + '@skipped' => $skipped, + ]); + } + + /** + * Sincroniza membros dos grupos de pesquisa usando o atributo member do LDAP. + * + * Para cada grupo de pesquisa, lê a lista de DNs do atributo configurado + * (padrão: 'member'), resolve cada DN para um usuário Drupal e gerencia + * as associações de membros do grupo (adiciona e remove conforme necessário). + * + * @param array $research_groups + * Array de dados dos grupos processados, com chave '_ldap_members'. + */ + public function syncMembersFromLdapAttribute(array $research_groups) { + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + + if (!($config->get('member_sync_enabled') ?? TRUE)) { + $this->logger->info('Member sync from LDAP attribute is disabled.'); + return; + } + + $this->logger->info('Starting member synchronization from LDAP attribute.'); + + $group_storage = $this->entityTypeManager->getStorage('group'); + $user_storage = $this->entityTypeManager->getStorage('user'); + $bundle_id = $this->getBundleId(); + $bundle_field = $this->getBundleField(); + + $total_added = 0; + $total_removed = 0; + $total_not_found = 0; + + foreach ($research_groups as $rg_data) { + $code = $rg_data['code']; + $ldap_members = $rg_data['_ldap_members'] ?? []; + + if (empty($ldap_members)) { + $this->logger->debug('No LDAP members for group @code.', ['@code' => $code]); + } + + // Localiza a entidade grupo correspondente + $groups = $group_storage->loadByProperties([ + $bundle_field => $bundle_id, + 'field_rg_code' => $code, + ]); + + if (empty($groups)) { + $this->logger->warning('Group with code @code not found for member sync.', ['@code' => $code]); + continue; + } + + $group = reset($groups); + + // Resolve DNs/UIDs LDAP para IDs de usuários Drupal + $expected_uids = []; + foreach ($ldap_members as $member_value) { + $username = $member_value; + // Se parece com um DN (contém '='), extrai o uid + if (strpos($member_value, '=') !== FALSE) { + if (preg_match('/uid=([^,]+)/i', $member_value, $matches)) { + $username = $matches[1]; + } + else { + $this->logger->debug('Could not extract uid from DN: @dn', ['@dn' => $member_value]); + continue; + } + } + + $uid = $this->getUserIdByUsername($username); + if ($uid) { + $expected_uids[$uid] = TRUE; + } + else { + $this->logger->debug('User @username (from @dn) not found in Drupal.', [ + '@username' => $username, + '@dn' => $member_value, + ]); + $total_not_found++; + } + } + + // Obtém membros atuais do grupo + $current_memberships = $group->getMembers(); + $current_uids = []; + foreach ($current_memberships as $membership) { + $member_entity = $membership->getUser(); + if ($member_entity) { + $current_uids[$member_entity->id()] = TRUE; + } + } + + // Adiciona novos membros + foreach (array_keys($expected_uids) as $uid) { + if (!isset($current_uids[$uid])) { + $user = $user_storage->load($uid); + if ($user) { + try { + $group->addMember($user); + $total_added++; + $this->logger->debug('Added user @uid to group @group.', [ + '@uid' => $uid, + '@group' => $group->label(), + ]); + // Append group to field_user_research_groups if not already there. + if ($user->hasField('field_user_research_groups')) { + $existing = $user->get('field_user_research_groups')->getValue(); + $already_set = FALSE; + foreach ($existing as $ref) { + if ($ref['target_id'] == $group->id()) { + $already_set = TRUE; + break; + } + } + if (!$already_set) { + self::$syncing = TRUE; + try { + $user->get('field_user_research_groups')->appendItem(['target_id' => $group->id()]); + $user->save(); + } + finally { + self::$syncing = FALSE; + } + } + } + } + catch (\Exception $e) { + $this->logger->error('Failed to add user @uid to group @group: @error', [ + '@uid' => $uid, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + } + } + + // Remove membros que não estão mais no LDAP + foreach (array_keys($current_uids) as $uid) { + if (!isset($expected_uids[$uid])) { + $user = $user_storage->load($uid); + if ($user) { + try { + $group->removeMember($user); + $total_removed++; + $this->logger->debug('Removed user @uid from group @group.', [ + '@uid' => $uid, + '@group' => $group->label(), + ]); + // Remove group from field_user_research_groups if present. + if ($user->hasField('field_user_research_groups')) { + $existing = $user->get('field_user_research_groups')->getValue(); + $updated = array_values(array_filter($existing, fn($ref) => $ref['target_id'] != $group->id())); + if (count($updated) !== count($existing)) { + self::$syncing = TRUE; + try { + $user->set('field_user_research_groups', $updated); + $user->save(); + } + finally { + self::$syncing = FALSE; + } + } + } + } + catch (\Exception $e) { + $this->logger->error('Failed to remove user @uid from group @group: @error', [ + '@uid' => $uid, + '@group' => $group->label(), + '@error' => $e->getMessage(), + ]); + } + } + } + } + } + + $this->logger->info('Member sync completed. Added: @added, Removed: @removed, Not found in Drupal: @not_found', [ + '@added' => $total_added, + '@removed' => $total_removed, + '@not_found' => $total_not_found, + ]); + } + + /** + * Determina o papel (role) de um usuário em um grupo baseado nos mapeamentos. + */ + protected function determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings) { + if (!$role_mapping_enabled || empty($role_mappings)) { + return NULL; + } + + foreach ($role_mappings as $mapping) { + $group_role = $mapping['group_role'] ?? ''; + $source = $mapping['source'] ?? 'user_field'; + $source_field = $mapping['source_field'] ?? ''; + $values = $mapping['values'] ?? []; + $group_field = $mapping['group_field'] ?? ''; + + if (empty($group_role) || empty($source_field)) { + continue; + } + + $user_value = NULL; + + if ($source === 'user_field') { + if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty()) { + $field = $user->get($source_field); + $field_type = $field->getFieldDefinition()->getType(); + if (in_array($field_type, ['entity_reference', 'entity_reference_revisions'])) { + $user_value = $field->target_id; + } + else { + $user_value = $field->value; + } + } + } + elseif ($source === 'ldap_attribute') { + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $ldap_user_data = $this->fetchUserLdapAttribute($user, $source_field, $config); + if ($ldap_user_data !== NULL) { + $user_value = $ldap_user_data; + } + } + elseif ($source === 'group_field_match') { + if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty() && + $group->hasField($group_field) && !$group->get($group_field)->isEmpty()) { + + $user_field = $user->get($source_field); + $group_field_obj = $group->get($group_field); + + $user_field_type = $user_field->getFieldDefinition()->getType(); + if (in_array($user_field_type, ['entity_reference', 'entity_reference_revisions'])) { + $user_value = $user_field->target_id; + } + else { + $user_value = $user_field->value; + } + + $group_field_type = $group_field_obj->getFieldDefinition()->getType(); + if (in_array($group_field_type, ['entity_reference', 'entity_reference_revisions'])) { + $group_value = $group_field_obj->target_id; + } + else { + $group_value = $group_field_obj->value; + } + + if ($user_value !== NULL && $group_value !== NULL && + strcasecmp(trim($user_value), trim($group_value)) === 0) { + return $group_role; + } + } + continue; + } + + if ($user_value !== NULL && !empty($values)) { + foreach ($values as $mapping_value) { + if (strcasecmp(trim($user_value), trim($mapping_value)) === 0) { + return $group_role; + } + } + } + } + + return NULL; + } + + /** + * Busca um atributo LDAP específico para um usuário. + */ + protected function fetchUserLdapAttribute($user, $attribute, $config) { + try { + $host = $config->get('ldap_host'); + $port = $config->get('ldap_port'); + $base_dn = $config->get('users_base_dn') ?? $config->get('ldap_base_dn'); + $bind_dn = $config->get('ldap_bind_dn'); + $bind_password = $config->get('ldap_bind_password'); + $users_filter = $config->get('users_filter') ?? '(objectClass=person)'; + + if (empty($host) || empty($base_dn)) { + return NULL; + } + + $ldap_connection = ldap_connect($host, $port); + if (!$ldap_connection) { + return NULL; + } + + ldap_set_option($ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($ldap_connection, LDAP_OPT_REFERRALS, 0); + + if (!empty($bind_dn) && !empty($bind_password)) { + $bind = @ldap_bind($ldap_connection, $bind_dn, $bind_password); + } + else { + $bind = @ldap_bind($ldap_connection); + } + + if (!$bind) { + ldap_close($ldap_connection); + return NULL; + } + + $username = $user->getAccountName(); + $filter = "(&{$users_filter}(uid={$username}))"; + $search = ldap_search($ldap_connection, $base_dn, $filter, [$attribute]); + + if (!$search) { + ldap_close($ldap_connection); + return NULL; + } + + $entries = ldap_get_entries($ldap_connection, $search); + ldap_close($ldap_connection); + + if (!empty($entries) && $entries['count'] > 0 && isset($entries[0][$attribute])) { + return $entries[0][$attribute][0] ?? NULL; + } + + return NULL; + } + catch (\Exception $e) { + $this->logger->error('Error fetching LDAP attribute @attribute for user @username: @error', [ + '@attribute' => $attribute, + '@username' => $user->getAccountName(), + '@error' => $e->getMessage(), + ]); + return NULL; + } + } + + /** + * Processes LDAP results using configured attribute mappings. + * + * @param array $results + * Array of LDAP results. + * + * @return array + * Array of processed research groups. + */ + protected function processLdapResults(array $results) { + $research_groups = []; + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $attribute_mappings = $config->get('attribute_mappings') ?: []; + + $code_mapping = NULL; + foreach ($attribute_mappings as $mapping) { + if ($mapping['field'] === 'field_rg_code') { + $code_mapping = $mapping; + break; + } + } + + if (!$code_mapping) { + $this->logger->error('No field_rg_code mapping found. Cannot process results.'); + return $research_groups; + } + + $this->logger->debug('Processing @count LDAP results with @mappings mappings', [ + '@count' => count($results), + '@mappings' => count($attribute_mappings), + ]); + + foreach ($results as $entry) { + $rg_data = []; + + foreach ($attribute_mappings as $mapping) { + $field = $mapping['field']; + $attribute = $mapping['attribute']; + $mapping_type = $mapping['mapping_type'] ?? 'simple'; + + $value = NULL; + if ($entry->hasAttribute($attribute)) { + $attr_values = $entry->getAttribute($attribute); + if (is_array($attr_values) && isset($attr_values[0])) { + $value = $attr_values[0]; + } + elseif (!empty($attr_values)) { + $value = $attr_values; + } + } + elseif ($mapping_type === 'user_reference') { + $this->logger->warning('Atributo LDAP "@attribute" não encontrado na entrada para o campo "@field". Verifique se o atributo está incluído na LDAP query.', [ + '@attribute' => $attribute, + '@field' => $field, + ]); + } + + if ($mapping_type === 'department_reference') { + if (!empty($value)) { + $dept_id = $this->getDepartmentIdByCode($value); + $rg_data[$field] = $dept_id; + } + else { + $rg_data[$field] = NULL; + } + } + elseif ($mapping_type === 'user_reference') { + if (!empty($value)) { + $username = $value; + // Se parece com DN (contém '='), tenta extrair o uid= ou o primeiro RDN. + if (strpos($value, '=') !== FALSE) { + if (preg_match('/uid=([^,]+)/i', $value, $matches)) { + $username = $matches[1]; + } + else { + // Extrai o valor do primeiro RDN (ex.: cn=João Silva,... → João Silva) + preg_match('/^[^=]+=([^,]+)/i', $value, $matches); + $username = $matches[1] ?? $value; + $this->logger->warning('Campo "@field": DN sem uid= encontrado ("@dn"). Usando primeiro RDN "@username" como username.', [ + '@field' => $field, + '@dn' => $value, + '@username' => $username, + ]); + } + } + + $user_id = $this->getUserIdByUsername($username); + + if ($user_id) { + $rg_data[$field] = $user_id; + } + else { + $this->logger->warning('Campo "@field": usuário "@username" (valor LDAP: "@value") não encontrado no Drupal. Campo ficará vazio.', [ + '@field' => $field, + '@username' => $username, + '@value' => $value, + ]); + $rg_data[$field] = NULL; + } + } + else { + $rg_data[$field] = NULL; + } + } + else { + $rg_data[$field] = $value; + } + } + + $code = $rg_data['field_rg_code'] ?? NULL; + if ($code) { + $result = [ + 'code' => $code, + 'name' => $rg_data['label'] ?? $rg_data['name'] ?? '', + 'phone' => $rg_data['field_rg_phone'] ?? '', + 'mail' => $rg_data['field_rg_mail'] ?? '', + ]; + + // Captura lista de membros do atributo LDAP configurado + $member_attribute = $config->get('member_attribute') ?: 'member'; + if ($entry->hasAttribute($member_attribute)) { + $result['_ldap_members'] = $entry->getAttribute($member_attribute) ?? []; + } + else { + $result['_ldap_members'] = []; + } + + $basic_rg_fields = ['field_rg_code', 'label', 'name', 'field_rg_phone', 'field_rg_mail']; + foreach ($rg_data as $key => $value) { + if (!in_array($key, $basic_rg_fields)) { + $result[$key] = $value; + } + } + + $research_groups[] = $result; + } + } + + return $research_groups; + } + + /** + * Gets Drupal user ID by username. + */ + protected function getUserIdByUsername($username) { + if (empty($username)) { + return NULL; + } + + $user_storage = $this->entityTypeManager->getStorage('user'); + $users = $user_storage->loadByProperties(['name' => $username]); + + if (!empty($users)) { + $user = reset($users); + return $user->id(); + } + + return NULL; + } + + /** + * Returns the Drupal group ID for a department with the given code. + */ + protected function getDepartmentIdByCode(string $code): ?int { + if (empty($code)) { + return NULL; + } + $groups = $this->entityTypeManager->getStorage('group') + ->loadByProperties(['field_dept_code' => $code]); + if (!empty($groups)) { + return (int) reset($groups)->id(); + } + $this->logger->debug('Department with field_dept_code @code not found.', ['@code' => $code]); + return NULL; + } + + /** + * Fetches raw LDAP Entry objects from the query. + */ + protected function fetchRawLdapEntries() { + $entries = []; + + if (!$this->ldapQueryStorage) { + $this->logger->error('Storage de queries LDAP não está disponível.'); + return $entries; + } + + try { + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + $query_id = $config->get('ldap_query_id') ?: 'research_group_sync'; + + $query_entity = $this->ldapQueryStorage->load($query_id); + + if (!$query_entity || !$query_entity->get('status')) { + $this->logger->error('Query LDAP não está disponível ou desabilitada.'); + return $entries; + } + + $server_id = $query_entity->getServerId(); + $base_dns = $query_entity->getProcessedBaseDns(); + $filter = $query_entity->getFilter(); + $attributes = $query_entity->getProcessedAttributes(); + + $this->ldapBridge->setServerById($server_id); + + if (!$this->ldapBridge->bind()) { + throw new \Exception('Falha ao conectar ao servidor LDAP'); + } + + $all_results = []; + foreach ($base_dns as $base_dn) { + try { + $ldap = $this->ldapBridge->get(); + $attr_options = []; + if (!empty($attributes)) { + $attr_options = ['filter' => $attributes]; + } + + $query = $ldap->query($base_dn, $filter, $attr_options); + $base_results = $query->execute(); + + if ($base_results instanceof \Traversable) { + $base_results = iterator_to_array($base_results); + } + + if (!empty($base_results) && is_array($base_results)) { + $all_results = array_merge($all_results, $base_results); + } + } + catch (\Exception $e) { + $this->logger->error('Erro na busca LDAP para Base DN @base_dn: @message', [ + '@base_dn' => $base_dn, + '@message' => $e->getMessage(), + ]); + } + } + + $entries = $all_results; + } + catch (\Exception $e) { + $this->logger->error('Erro ao buscar entries LDAP: @message', ['@message' => $e->getMessage()]); + } + + return $entries; + } + + /** + * Returns the group entity storage. + */ + protected function getEntityStorage() { + return $this->entityTypeManager->getStorage('group'); + } + + /** + * Returns the group type ID from configuration. + */ + protected function getBundleId() { + $config = $this->configFactory->get('ldap_research_groups_sync.settings'); + return $config->get('group_type_id') ?: 'research_group'; + } + + /** + * Returns the name field for groups. + */ + protected function getNameField() { + return 'label'; + } + + /** + * Returns the bundle field name for groups. + */ + protected function getBundleField() { + return 'type'; + } + +} diff --git a/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php b/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php new file mode 100644 index 0000000..3d9159e --- /dev/null +++ b/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php @@ -0,0 +1,31 @@ +condition('type', 'research_group'); + return $query; + } + +} diff --git a/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po b/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po new file mode 100644 index 0000000..cfe9436 --- /dev/null +++ b/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po @@ -0,0 +1,298 @@ +# Portuguese (Brazil) translation for LDAP Research Groups Sync module +# Copyright (c) 2026 +# This file is distributed under the same license as the LDAP Research Groups Sync module. +# +msgid "" +msgstr "" +"Project-Id-Version: LDAP Research Groups Sync 1.0.0\n" +"POT-Creation-Date: 2026-02-27 00:00+0000\n" +"PO-Revision-Date: 2026-02-27 00:00+0000\n" +"Language-Team: Portuguese (Brazil)\n" +"Language: pt-br\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: ldap_research_groups_sync.routing.yml +msgid "LDAP Research Groups Sync" +msgstr "Sincronização de Grupos de Pesquisa LDAP" + +#: ldap_research_groups_sync.routing.yml +msgid "Access Rules" +msgstr "Regras de Acesso" + +#: ldap_research_groups_sync.routing.yml +msgid "Access Rule" +msgstr "Regra de Acesso" + +#: ldap_research_groups_sync.module +msgid "This module synchronizes research groups from an LDAP server to groups through cron." +msgstr "Este módulo sincroniza grupos de pesquisa de um servidor LDAP para grupos através do cron." + +#: ldap_research_groups_sync.install +msgid "Please ensure your group type has the required fields: field_rg_code, field_rg_phone, field_rg_mail, and field_rg_department (for department reference)." +msgstr "Certifique-se de que o tipo de grupo possui os campos obrigatórios: field_rg_code, field_rg_phone, field_rg_mail e field_rg_department (para referência de departamento)." + +msgid "LDAP Research Groups Sync module uninstalled." +msgstr "Módulo LDAP Research Groups Sync desinstalado." + +#: src/Form/LdapResearchGroupsSyncConfigForm.php + +msgid "Group Type" +msgstr "Tipo de Grupo" + +msgid "Target Group Type" +msgstr "Tipo de Grupo Alvo" + +msgid "Select the group type where research groups will be synchronized.
Required fields: field_rg_code, field_rg_phone, field_rg_mail" +msgstr "Selecione o tipo de grupo onde os grupos de pesquisa serão sincronizados.
Campos obrigatórios: field_rg_code, field_rg_phone, field_rg_mail" + +msgid "- Select a group type -" +msgstr "- Selecione um tipo de grupo -" + +msgid "The default \"research_group\" group type is not installed." +msgstr "O tipo de grupo padrão \"research_group\" não está instalado." + +msgid "Install Default Configuration" +msgstr "Instalar Configuração Padrão" + +msgid "The button above will create:Or you can create a custom group type manually." +msgstr "O botão acima irá criar:Ou você pode criar um tipo de grupo personalizado manualmente." + +msgid "Create New Group Type" +msgstr "Criar Novo Tipo de Grupo" + +msgid "Manage Group Type" +msgstr "Gerenciar Tipo de Grupo" + +msgid "Manage Fields" +msgstr "Gerenciar Campos" + +msgid "LDAP Server" +msgstr "Servidor LDAP" + +msgid "Select the LDAP server configured for research groups synchronization." +msgstr "Selecione o servidor LDAP configurado para sincronização de grupos de pesquisa." + +msgid "- Select a server -" +msgstr "- Selecione um servidor -" + +msgid "No LDAP server found. Configure an LDAP server first." +msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro." + +msgid "LDAP Query" +msgstr "Consulta LDAP" + +msgid "Select the LDAP query that will be used to search for research groups." +msgstr "Selecione a consulta LDAP que será usada para buscar grupos de pesquisa." + +msgid "- Select a query -" +msgstr "- Selecione uma query -" + +msgid "No LDAP query found for the selected server. Create a new LDAP query." +msgstr "Nenhuma query LDAP encontrada para o servidor selecionado. Crie uma nova query LDAP." + +msgid "Create New LDAP Query" +msgstr "Criar Nova Query LDAP" + +msgid "Edit Selected Query" +msgstr "Editar Query Selecionada" + +msgid "Attribute Mapping" +msgstr "Mapeamento de Atributos" + +msgid "Configure how LDAP attributes are mapped to target entity fields.
Note: For \"User Reference\" mappings, the search attribute will be automatically obtained from the \"Account name attribute\" field configured in the selected LDAP server." +msgstr "Configure como os atributos LDAP são mapeados para os campos da entidade alvo.
Nota: Para mapeamentos do tipo \"Referência de Usuário\", o atributo de busca será obtido automaticamente do campo \"Atributo de nome de conta\" configurado no servidor LDAP selecionado." + +msgid "Entity Field" +msgstr "Campo da Entidade" + +msgid "LDAP Attribute" +msgstr "Atributo LDAP" + +msgid "Mapping Type" +msgstr "Tipo de Mapeamento" + +msgid "Remove" +msgstr "Remover" + +msgid "No mappings configured." +msgstr "Nenhum mapeamento configurado." + +msgid "- Select a field -" +msgstr "- Selecione um campo -" + +msgid "- Select an attribute -" +msgstr "- Selecione um atributo -" + +msgid "Simple (text)" +msgstr "Simples (texto)" + +msgid "User Reference (DN → User)" +msgstr "Referência de Usuário (DN → Usuário)" + +msgid "Department Reference (code → Department)" +msgstr "Referência de Departamento (código → Departamento)" + +msgid "Add Mapping" +msgstr "Adicionar Mapeamento" + +msgid "Remove Selected" +msgstr "Remover Selecionados" + +msgid "Member Synchronization" +msgstr "Sincronização de Membros" + +msgid "Enable member synchronization from LDAP attribute" +msgstr "Habilitar sincronização de membros a partir do atributo LDAP" + +msgid "When enabled, group memberships are managed based on the LDAP member attribute of each group entry. Members are added or removed to match the LDAP list." +msgstr "Quando habilitado, as associações de membros do grupo são gerenciadas com base no atributo de membros LDAP de cada entrada de grupo. Membros são adicionados ou removidos para corresponder à lista LDAP." + +msgid "Member LDAP attribute" +msgstr "Atributo LDAP de membros" + +msgid "Name of the LDAP attribute on each group entry that lists its members (as DNs or UIDs). Common values: member, uniqueMember, memberUid. The attribute must be included in the LDAP query." +msgstr "Nome do atributo LDAP em cada entrada de grupo que lista seus membros (como DNs ou UIDs). Valores comuns: member, uniqueMember, memberUid. O atributo deve estar incluído na query LDAP." + +msgid "Group Role Mapping" +msgstr "Mapeamento de Papéis do Grupo" + +msgid "Configure how user attributes map to group roles. Each role can have its own mapping criteria." +msgstr "Configure como os atributos do usuário são mapeados para papéis do grupo. Cada papel pode ter seus próprios critérios de mapeamento." + +msgid "Enable role mapping" +msgstr "Habilitar mapeamento de papéis" + +msgid "When enabled, users will be assigned group roles based on the mappings below." +msgstr "Quando habilitado, os usuários receberão papéis de grupo com base nos mapeamentos abaixo." + +msgid "Group Role" +msgstr "Papel do Grupo" + +msgid "Source" +msgstr "Fonte" + +msgid "User Field / LDAP Attribute" +msgstr "Campo do Usuário / Atributo LDAP" + +msgid "Group Field / Values" +msgstr "Campo do Grupo / Valores" + +msgid "No role mappings configured. Users will be assigned the default role." +msgstr "Nenhum mapeamento de papel configurado. Os usuários receberão o papel padrão." + +msgid "- Select a role -" +msgstr "- Selecione um papel -" + +msgid "User Field" +msgstr "Campo do Usuário" + +msgid "LDAP Attribute" +msgstr "Atributo LDAP" + +msgid "Group Field Match" +msgstr "Correspondência de Campo do Grupo" + +msgid "- Select user field -" +msgstr "- Selecione campo do usuário -" + +msgid "- Select attribute/field -" +msgstr "- Selecione atributo/campo -" + +msgid "- Select group field -" +msgstr "- Selecione campo do grupo -" + +msgid "e.g., Director, Manager" +msgstr "ex: Diretor, Gerente" + +msgid "Add Role Mapping" +msgstr "Adicionar Mapeamento de Papel" + +msgid "Save Configuration" +msgstr "Salvar Configuração" + +msgid "Test Connection" +msgstr "Testar Conexão" + +msgid "Synchronize Research Groups" +msgstr "Sincronizar Grupos de Pesquisa" + +msgid "Error loading LDAP servers: @message" +msgstr "Erro ao carregar servidores LDAP: @message" + +msgid "Error loading query attributes: @message" +msgstr "Erro ao carregar atributos da query: @message" + +msgid "LDAP attribute is required when field is selected." +msgstr "O atributo LDAP é obrigatório quando um campo é selecionado." + +msgid "Vocabulary field is required when attribute is selected." +msgstr "O campo vocabulário é obrigatório quando um atributo é selecionado." + +msgid "Configuration saved successfully." +msgstr "Configuração salva com sucesso." + +msgid "Please select an LDAP server to test." +msgstr "Por favor, selecione um servidor LDAP para testar." + +msgid "LDAP server \"@server\" is configured and active." +msgstr "Servidor LDAP \"@server\" está configurado e ativo." + +msgid "LDAP server not found or inactive." +msgstr "Servidor LDAP não encontrado ou inativo." + +msgid "Error testing connection: @message" +msgstr "Erro ao testar conexão: @message" + +msgid "Please fix the errors in the form before running the synchronization." +msgstr "Corrija os erros no formulário antes de executar a sincronização." + +msgid "Synchronization completed successfully. Check the logs for details." +msgstr "Sincronização executada com sucesso. Verifique os logs para detalhes." + +msgid "Error during synchronization: @message" +msgstr "Erro durante a sincronização: @message" + +msgid "Internal" +msgstr "Interno" + +msgid "Outsider" +msgstr "Externo" + +msgid "Error loading group roles: @message" +msgstr "Erro ao carregar papéis do grupo: @message" + +msgid "Error loading user fields: @message" +msgstr "Erro ao carregar campos do usuário: @message" + +msgid "The ldap_query module is not available. LDAP queries cannot be loaded." +msgstr "O módulo ldap_query não está disponível. As queries LDAP não podem ser carregadas." + +msgid "Error loading LDAP queries: @message" +msgstr "Erro ao carregar queries LDAP: @message" + +msgid "Label (group title)" +msgstr "Rótulo (título do grupo)" + +msgid "Group type \"@type\" not found. Please create it first or select a different group type." +msgstr "Tipo de grupo \"@type\" não encontrado. Crie-o primeiro ou selecione um tipo de grupo diferente." + +msgid "Error loading group types: @message" +msgstr "Erro ao carregar tipos de grupo: @message" + +msgid "Configuration directory not found: @path" +msgstr "Diretório de configuração não encontrado: @path" + +msgid "Configuration @name already exists, skipping." +msgstr "Configuração @name já existe, pulando." + +msgid "Successfully imported @count configuration files." +msgstr "Importados com sucesso @count arquivos de configuração." + +msgid "No new configuration files were imported. All configurations already exist." +msgstr "Nenhum arquivo de configuração novo foi importado. Todas as configurações já existem." + +msgid "Error importing configuration: @message" +msgstr "Erro ao importar configuração: @message" diff --git a/src/Form/AccessRuleFormBase.php b/src/Form/AccessRuleFormBase.php new file mode 100644 index 0000000..8cdc32f --- /dev/null +++ b/src/Form/AccessRuleFormBase.php @@ -0,0 +1,771 @@ +configFactory = $config_factory; + $this->entityTypeManager = $entity_type_manager; + $this->bundleInfo = $bundle_info; + $this->entityFieldManager = $entity_field_manager; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('entity_type.manager'), + $container->get('entity_type.bundle.info'), + $container->get('entity_field.manager'), + $container->get('current_route_match') + ); + } + + /** + * Returns the config object name for the implementing module. + * + * @return string + * E.g. 'ldap_departments_sync.settings'. + */ + abstract protected function getConfigName(): string; + + /** + * Returns the route name for the access rules listing page. + * + * @return string + * E.g. 'ldap_departments_sync.access_rules'. + */ + abstract protected function getAccessRulesRoute(): string; + + /** + * Returns the default group type ID used when the config has no value set. + * + * @return string + * E.g. 'departments' or 'research_group'. + */ + abstract protected function getDefaultGroupTypeId(): string; + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $rule_index = 'new') { + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + // Attach dialog library so the close button works. + $form['#attached']['library'][] = 'core/drupal.dialog.ajax'; + + // Resolve the rule index (from route or form_state for AJAX rebuilds). + $resolved_index = $form_state->get('rule_index') ?? $rule_index; + $form_state->set('rule_index', $resolved_index); + + // Load existing rule data if editing. + $rule = $this->loadRule($resolved_index); + + // ---- Resolve current entity_type and bundle (considering AJAX changes) ---- + $entity_type_id = $form_state->getValue('entity_type') ?? ($rule['entity_type'] ?? ''); + $bundle = $form_state->getValue('bundle') ?? ($rule['bundle'] ?? ''); + + // ---- Basic settings -------------------------------------------------------- + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#description' => $this->t('Human-readable description of this rule.'), + '#default_value' => $rule['label'] ?? '', + '#required' => TRUE, + '#maxlength' => 128, + ]; + + $form['enabled'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enabled'), + '#default_value' => $rule['enabled'] ?? TRUE, + ]; + + // ---- Entity type ----------------------------------------------------------- + $entity_type_options = $this->getContentEntityTypeOptions(); + + $form['entity_type'] = [ + '#type' => 'select', + '#title' => $this->t('Entity Type'), + '#options' => $entity_type_options, + '#empty_option' => $this->t('- Select entity type -'), + '#default_value' => $entity_type_id, + '#required' => TRUE, + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + ]; + + // ---- Bundle ---------------------------------------------------------------- + $bundle_options = []; + if (!empty($entity_type_id)) { + $bundle_options = $this->getBundleOptions($entity_type_id); + } + + $form['bundle'] = [ + '#type' => 'select', + '#title' => $this->t('Bundle'), + '#description' => $this->t('Leave empty to apply to all bundles of the selected entity type.'), + '#options' => $bundle_options, + '#empty_option' => $this->t('- All bundles -'), + '#default_value' => $bundle, + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + ]; + + // ---- Operations ------------------------------------------------------------ + $form['operations'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Operations'), + '#options' => [ + 'create' => $this->t('Create'), + 'update' => $this->t('Update'), + 'delete' => $this->t('Delete'), + 'view' => $this->t('View'), + ], + '#default_value' => $rule['operations'] ?? ['create'], + '#required' => TRUE, + ]; + + // ---- Mode ------------------------------------------------------------------ + $form['mode'] = [ + '#type' => 'radios', + '#title' => $this->t('Mode'), + '#options' => [ + 'restrictive' => $this->t('Restrictive — deny access to users who do not match this rule'), + 'additive' => $this->t('Additive — only grant access; never deny other users'), + ], + '#default_value' => $rule['mode'] ?? 'restrictive', + ]; + + // ---- Membership requirements ----------------------------------------------- + $form['membership'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Membership requirements'), + '#description' => $this->t( + 'A user is authorized if they satisfy at least one card: ' + . 'member of that group and holding at least one of the checked roles. ' + . 'Each card can specify a different group and different roles.' + ), + ]; + + $membership_count = $form_state->get('membership_count'); + + // Prefer form state values (preserves user edits during AJAX rebuilds). + // Normalize from submitted format: checkboxes return [id => id|0], we + // need [id, ...] for #default_value. + $memberships_from_state = $form_state->getValue('memberships'); + if ($memberships_from_state !== NULL) { + $existing_memberships = []; + foreach ($memberships_from_state as $mem) { + $existing_memberships[] = [ + 'group_id' => $mem['group_id'] ?? '', + 'roles' => is_array($mem['roles'] ?? NULL) + ? array_values(array_filter($mem['roles'])) + : [], + ]; + } + } + else { + $existing_memberships = $rule['memberships'] ?? []; + } + + if ($membership_count === NULL) { + $membership_count = max(1, count($existing_memberships)); + $form_state->set('membership_count', $membership_count); + } + + $group_options = $this->getGroupOptions(); + $role_options = $this->getGroupRoleOptions(); + + // #tree => TRUE ensures values are nested as memberships[m][group_id], + // memberships[m][roles], etc. Without it, all group_id fields would + // collide at the top-level form values. + $form['membership']['memberships'] = [ + '#type' => 'container', + '#tree' => TRUE, + ]; + + // Render one card (container) per membership entry. + for ($m = 0; $m < $membership_count; $m++) { + $mem = $existing_memberships[$m] ?? ['group_id' => '', 'roles' => []]; + + $form['membership']['memberships'][$m] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['membership-card'], + 'style' => 'border:1px solid #ccc;border-radius:4px;padding:12px 16px;margin-bottom:12px;background:#fafafa', + ], + ]; + + $form['membership']['memberships'][$m]['group_id'] = [ + '#type' => 'select', + '#title' => $this->t('Group'), + '#options' => $group_options, + '#empty_option' => $this->t('- Select a group -'), + '#default_value' => $mem['group_id'] ?? '', + '#required' => FALSE, + ]; + + // Checkboxes display the full label — no truncation. + $form['membership']['memberships'][$m]['roles'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Required roles (user must hold at least one)'), + '#options' => $role_options, + '#default_value' => $mem['roles'] ?? [], + ]; + + $form['membership']['memberships'][$m]['remove'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove this group'), + '#name' => 'remove_membership_' . $m, + '#submit' => ['::removeOneMembership'], + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [['memberships']], + '#attributes' => ['class' => ['button', 'button--danger', 'button--small']], + ]; + } + + $form['membership']['add_membership'] = [ + '#type' => 'submit', + '#value' => $this->t('+ Add group'), + '#submit' => ['::addMembership'], + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [['memberships']], + '#attributes' => ['class' => ['button']], + ]; + + // ---- Field conditions (update / delete / view only) ----------------------- + $form['field_conditions_wrapper'] = [ + '#type' => 'details', + '#title' => $this->t('Field conditions (optional — only evaluated for update/delete/view)'), + '#open' => !empty($rule['field_conditions']), + '#description' => $this->t('All conditions must be true for the rule to apply to an existing entity. For create operations conditions are ignored.'), + ]; + + $field_options = []; + if (!empty($entity_type_id)) { + $field_options = $this->getFieldOptions($entity_type_id, $bundle ?: NULL); + } + + $cond_count = $form_state->get('condition_count'); + if ($cond_count === NULL) { + $cond_count = max(1, count($rule['field_conditions'] ?? [])); + $form_state->set('condition_count', $cond_count); + } + + $existing_conditions = $rule['field_conditions'] ?? []; + + $form['field_conditions_wrapper']['field_conditions'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Field'), + $this->t('Value'), + $this->t('Remove'), + ], + ]; + + for ($c = 0; $c < $cond_count; $c++) { + $cond = $existing_conditions[$c] ?? ['field' => '', 'value' => '']; + + if (!empty($field_options)) { + $form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [ + '#type' => 'select', + '#options' => $field_options, + '#empty_option' => $this->t('- Select field -'), + '#default_value' => $cond['field'], + ]; + } + else { + $form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [ + '#type' => 'textfield', + '#placeholder' => $this->t('field_machine_name'), + '#default_value' => $cond['field'], + '#size' => 24, + ]; + } + + $form['field_conditions_wrapper']['field_conditions'][$c]['value'] = [ + '#type' => 'textfield', + '#placeholder' => $this->t('Expected value'), + '#default_value' => $cond['value'], + '#size' => 24, + ]; + + $form['field_conditions_wrapper']['field_conditions'][$c]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + $form['field_conditions_wrapper']['condition_actions'] = [ + '#type' => 'container', + ]; + + $form['field_conditions_wrapper']['condition_actions']['add_condition'] = [ + '#type' => 'submit', + '#value' => $this->t('Add condition'), + '#submit' => ['::addCondition'], + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [['field_conditions']], + ]; + + $form['field_conditions_wrapper']['condition_actions']['remove_conditions'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove selected conditions'), + '#submit' => ['::removeSelectedConditions'], + '#ajax' => [ + 'callback' => '::updateDependentFields', + 'wrapper' => 'access-rule-form-wrapper', + 'effect' => 'fade', + ], + '#limit_validation_errors' => [['field_conditions']], + ]; + + // ---- Submit ---------------------------------------------------------------- + $form['actions'] = ['#type' => 'actions']; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save rule'), + '#button_type' => 'primary', + '#ajax' => [ + 'callback' => '::ajaxSave', + 'wrapper' => 'access-rule-form-wrapper', + ], + ]; + + $form['actions']['cancel'] = [ + '#type' => 'submit', + '#value' => $this->t('Cancel'), + '#submit' => [], + '#limit_validation_errors' => [], + '#ajax' => [ + 'callback' => '::cancelDialog', + ], + '#attributes' => ['class' => ['button']], + ]; + + return $form; + } + + /** + * AJAX callback: replaces the entire form wrapper (entity_type/bundle/field + * changes and condition add/remove all reuse this single callback). + */ + public function updateDependentFields(array &$form, FormStateInterface $form_state) { + return $form; + } + + /** + * Submit handler: adds a blank membership card. + * + * Explicitly normalizes current values and appends an empty entry so the + * new card has no pre-filled group or roles. + */ + public function addMembership(array &$form, FormStateInterface $form_state) { + $current = $form_state->getValue('memberships') ?? []; + + // Normalize existing entries (checkboxes submit [id => id|0] format). + $normalized = []; + foreach ($current as $mem) { + $normalized[] = [ + 'group_id' => $mem['group_id'] ?? '', + 'roles' => is_array($mem['roles'] ?? NULL) + ? array_values(array_filter($mem['roles'])) + : [], + ]; + } + + // Append a truly empty entry for the new card. + $normalized[] = ['group_id' => '', 'roles' => []]; + + $form_state->setValue('memberships', $normalized); + $form_state->set('membership_count', count($normalized)); + $form_state->setRebuild(); + } + + /** + * Submit handler: removes the single membership card whose button was clicked. + */ + public function removeOneMembership(array &$form, FormStateInterface $form_state) { + $trigger = $form_state->getTriggeringElement(); + preg_match('/remove_membership_(\d+)/', $trigger['#name'] ?? '', $matches); + $index = isset($matches[1]) ? (int) $matches[1] : -1; + + $memberships = $form_state->getValue('memberships') ?? []; + + // Normalize and remove the target entry. + $normalized = []; + foreach ($memberships as $i => $mem) { + if ($i === $index) { + continue; + } + $normalized[] = [ + 'group_id' => $mem['group_id'] ?? '', + 'roles' => is_array($mem['roles'] ?? NULL) + ? array_values(array_filter($mem['roles'])) + : [], + ]; + } + + $form_state->setValue('memberships', $normalized); + $form_state->set('membership_count', max(1, count($normalized))); + $form_state->setRebuild(); + } + + /** + * Submit handler: adds a blank condition row. + */ + public function addCondition(array &$form, FormStateInterface $form_state) { + $form_state->set('condition_count', ($form_state->get('condition_count') ?? 1) + 1); + $form_state->setRebuild(); + } + + /** + * Submit handler: removes checked condition rows. + */ + public function removeSelectedConditions(array &$form, FormStateInterface $form_state) { + $conditions = $form_state->getValue('field_conditions') ?? []; + $kept = array_values(array_filter($conditions, fn($c) => empty($c['remove']))); + $form_state->setValue('field_conditions', $kept); + $form_state->set('condition_count', max(1, count($kept))); + $form_state->setRebuild(); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $operations = array_filter($form_state->getValue('operations') ?? []); + if (empty($operations)) { + $form_state->setErrorByName('operations', $this->t('Select at least one operation.')); + } + + $memberships = $form_state->getValue('memberships') ?? []; + $valid = array_filter($memberships, fn($m) => !empty($m['group_id'])); + if (empty($valid)) { + $form_state->setErrorByName('memberships', $this->t('Add at least one group membership requirement.')); + } + + foreach ($valid as $idx => $mem) { + if (empty($mem['roles'])) { + $form_state->setErrorByName( + "memberships][$idx][roles", + $this->t('Select at least one required role for each group.') + ); + } + } + } + + /** + * AJAX callback: closes the modal and redirects after a successful save. + * + * The actual save is performed by submitForm(), which Drupal always runs + * before the AJAX callback. This callback must NOT call saveRule() again + * to avoid saving the rule twice. + */ + public function ajaxSave(array &$form, FormStateInterface $form_state) { + if ($form_state->hasAnyErrors()) { + // Redisplay the form with error messages. + return $form; + } + + $response = new AjaxResponse(); + $response->addCommand(new CloseModalDialogCommand()); + $response->addCommand(new RedirectCommand( + Url::fromRoute($this->getAccessRulesRoute())->toString() + )); + + return $response; + } + + /** + * AJAX callback: closes the modal without saving. + */ + public function cancelDialog(array &$form, FormStateInterface $form_state): AjaxResponse { + $response = new AjaxResponse(); + $response->addCommand(new CloseModalDialogCommand()); + return $response; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Runs for both AJAX and non-AJAX submissions. + $this->saveRule($form_state); + $form_state->setRedirectUrl(Url::fromRoute($this->getAccessRulesRoute())); + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * Loads a rule from config by index ('new' returns defaults). + */ + protected function loadRule($rule_index): array { + if ($rule_index === 'new') { + return []; + } + $rules = $this->configFactory + ->get($this->getConfigName()) + ->get('access_rules') ?? []; + + return $rules[(int) $rule_index] ?? []; + } + + /** + * Persists the rule to config. + */ + protected function saveRule(FormStateInterface $form_state): void { + $rule_index = $form_state->get('rule_index') ?? 'new'; + + $operations = array_values(array_filter($form_state->getValue('operations') ?? [])); + + $conditions_raw = $form_state->getValue('field_conditions') ?? []; + $conditions = []; + foreach ($conditions_raw as $cond) { + if (!empty($cond['remove']) || empty($cond['field'])) { + continue; + } + $conditions[] = [ + 'field' => $cond['field'], + 'value' => $cond['value'] ?? '', + ]; + } + + $memberships_raw = $form_state->getValue('memberships') ?? []; + $memberships = []; + foreach ($memberships_raw as $mem) { + if (empty($mem['group_id'])) { + continue; + } + // Checkboxes return [role_id => role_id] for checked, [role_id => 0] for unchecked. + $roles = array_values(array_filter((array) ($mem['roles'] ?? []))); + $memberships[] = [ + 'group_id' => (int) $mem['group_id'], + 'roles' => $roles, + ]; + } + + $new_rule = [ + 'label' => $form_state->getValue('label'), + 'entity_type' => $form_state->getValue('entity_type'), + 'bundle' => $form_state->getValue('bundle') ?? '', + 'operations' => $operations, + 'mode' => $form_state->getValue('mode') ?? 'restrictive', + 'memberships' => $memberships, + 'field_conditions' => $conditions, + 'enabled' => (bool) $form_state->getValue('enabled'), + ]; + + $config = $this->configFactory->getEditable($this->getConfigName()); + $rules = $config->get('access_rules') ?? []; + + if ($rule_index === 'new') { + $rules[] = $new_rule; + } + else { + $rules[(int) $rule_index] = $new_rule; + } + + $config->set('access_rules', array_values($rules))->save(); + } + + /** + * Returns content entity type options suitable for a select element. + */ + protected function getContentEntityTypeOptions(): array { + $options = []; + foreach ($this->entityTypeManager->getDefinitions() as $id => $definition) { + if (!($definition instanceof \Drupal\Core\Entity\ContentEntityTypeInterface)) { + continue; + } + // Skip internal group-related and config entity types. + if (in_array($id, ['group_content', 'group_relationship'], TRUE)) { + continue; + } + $options[$id] = (string) $definition->getLabel(); + } + asort($options); + return $options; + } + + /** + * Returns bundle options for a given entity type. + */ + protected function getBundleOptions(string $entity_type_id): array { + $options = []; + try { + foreach ($this->bundleInfo->getBundleInfo($entity_type_id) as $bundle_id => $info) { + $options[$bundle_id] = (string) ($info['label'] ?? $bundle_id); + } + } + catch (\Exception $e) { + // Return empty if the entity type has no bundles. + } + asort($options); + return $options; + } + + /** + * Returns field options for the given entity type and (optional) bundle. + */ + protected function getFieldOptions(string $entity_type_id, ?string $bundle): array { + $options = []; + try { + $bundle = $bundle ?: $entity_type_id; + $definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle); + $skip = ['uuid', 'langcode', 'default_langcode', 'revision_translation_affected']; + foreach ($definitions as $name => $def) { + if (in_array($name, $skip, TRUE)) { + continue; + } + $options[$name] = (string) $def->getLabel() . ' (' . $name . ')'; + } + } + catch (\Exception $e) { + // Return empty on error. + } + asort($options); + return $options; + } + + /** + * Returns options for all groups of the configured group type. + */ + protected function getGroupOptions(): array { + $options = []; + try { + $group_type_id = $this->configFactory + ->get($this->getConfigName()) + ->get('group_type_id') ?: $this->getDefaultGroupTypeId(); + + $groups = $this->entityTypeManager + ->getStorage('group') + ->loadByProperties(['type' => $group_type_id]); + + foreach ($groups as $group) { + $options[$group->id()] = $group->label(); + } + asort($options); + } + catch (\Exception $e) { + // Return empty on error. + } + return $options; + } + + /** + * Returns options for all roles of the configured group type. + */ + protected function getGroupRoleOptions(): array { + $options = []; + try { + $group_type_id = $this->configFactory + ->get($this->getConfigName()) + ->get('group_type_id') ?: $this->getDefaultGroupTypeId(); + + $roles = $this->entityTypeManager + ->getStorage('group_role') + ->loadByProperties(['group_type' => $group_type_id]); + + foreach ($roles as $role) { + $scope_labels = [ + 'individual' => $this->t('Individual'), + 'insider' => $this->t('Insider'), + 'outsider' => $this->t('Outsider'), + ]; + $scope = $role->getScope(); + $suffix = isset($scope_labels[$scope]) ? ' [' . $scope_labels[$scope] . ']' : ''; + $options[$role->id()] = $role->label() . $suffix; + } + } + catch (\Exception $e) { + // Return empty on error. + } + return $options; + } + +} diff --git a/src/Form/AccessRulesFormBase.php b/src/Form/AccessRulesFormBase.php new file mode 100644 index 0000000..d891eba --- /dev/null +++ b/src/Form/AccessRulesFormBase.php @@ -0,0 +1,221 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('config.typed'), + $container->get('entity_type.manager') + ); + } + + /** + * Returns the config object name for the implementing module. + * + * @return string + * E.g. 'ldap_departments_sync.settings'. + */ + abstract protected function getConfigName(): string; + + /** + * Returns the route name for the single-rule edit/create form. + * + * @return string + * E.g. 'ldap_departments_sync.access_rule_form'. + */ + abstract protected function getAccessRuleFormRoute(): string; + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return [$this->getConfigName()]; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['#attached']['library'][] = 'core/drupal.dialog.ajax'; + + $form['description'] = [ + '#type' => 'markup', + '#markup' => '

' . $this->t( + 'Define rules that restrict or grant entity operations based on group membership.
' + . 'Restrictive: if a matching rule exists and the user does not satisfy it, access is denied.
' + . 'Additive: the rule only grants extra access; other users keep their default permissions.
' + . 'Field conditions are only evaluated for update/delete/view (entity already exists).' + ) . '

', + ]; + + $existing_rules = $this->config($this->getConfigName())->get('access_rules') ?? []; + + $form['summary'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Label'), + $this->t('Entity type / Bundle'), + $this->t('Operations'), + $this->t('Groups / Roles'), + $this->t('Mode'), + $this->t('Status'), + $this->t('Edit'), + $this->t('Remove'), + ], + '#empty' => $this->t('No access rules defined. Click "Add Rule" to create the first one.'), + '#attributes' => ['class' => ['access-rules-summary-table']], + ]; + + foreach ($existing_rules as $i => $rule) { + $bundle_part = !empty($rule['bundle']) + ? ' / ' . $rule['bundle'] . '' + : ' / ' . $this->t('all bundles') . ''; + + $form['summary'][$i]['label'] = [ + '#markup' => '' . ($rule['label'] ?: $this->t('(no label)')) . '', + ]; + + $form['summary'][$i]['entity_bundle'] = [ + '#markup' => ($rule['entity_type'] ?? '?') . $bundle_part, + ]; + + $form['summary'][$i]['operations'] = [ + '#markup' => implode(', ', $rule['operations'] ?? []), + ]; + + $membership_lines = []; + foreach ($rule['memberships'] ?? [] as $mem) { + try { + $group = $this->entityTypeManager->getStorage('group')->load($mem['group_id'] ?? 0); + $group_label = $group ? $group->label() : '#' . ($mem['group_id'] ?? '?'); + } + catch (\Exception $e) { + $group_label = '#' . ($mem['group_id'] ?? '?'); + } + $roles = implode(', ', $mem['roles'] ?? []); + $membership_lines[] = $group_label . ($roles ? ' (' . $roles . ')' : ''); + } + $form['summary'][$i]['memberships_summary'] = [ + '#markup' => $membership_lines + ? implode('
', $membership_lines) + : '' . $this->t('none') . '', + ]; + + $form['summary'][$i]['mode'] = [ + '#markup' => ($rule['mode'] ?? 'restrictive') === 'additive' + ? $this->t('Additive') + : $this->t('Restrictive'), + ]; + + $form['summary'][$i]['enabled'] = [ + '#markup' => ($rule['enabled'] ?? TRUE) + ? '✓ ' . $this->t('On') . '' + : '✗ ' . $this->t('Off') . '', + ]; + + $form['summary'][$i]['edit'] = [ + '#type' => 'link', + '#title' => $this->t('Edit'), + '#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => $i]), + '#attributes' => [ + 'class' => ['use-ajax', 'button', 'button--small'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => json_encode([ + 'width' => 860, + 'title' => $this->t('Edit Access Rule'), + ]), + ], + ]; + + $form['summary'][$i]['remove'] = [ + '#type' => 'checkbox', + '#default_value' => FALSE, + ]; + } + + $form['actions'] = ['#type' => 'actions']; + + $form['actions']['add_rule'] = [ + '#type' => 'link', + '#title' => $this->t('Add Rule'), + '#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => 'new']), + '#attributes' => [ + 'class' => ['use-ajax', 'button', 'button--primary'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => json_encode([ + 'width' => 860, + 'title' => $this->t('New Access Rule'), + ]), + ], + ]; + + $form['actions']['remove_selected'] = [ + '#type' => 'submit', + '#value' => $this->t('Remove Selected'), + '#limit_validation_errors' => [['summary']], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $summary_values = $form_state->getValue('summary') ?? []; + $config = $this->configFactory()->getEditable($this->getConfigName()); + $existing_rules = $config->get('access_rules') ?? []; + + $kept = []; + foreach ($existing_rules as $i => $rule) { + if (empty($summary_values[$i]['remove'])) { + $kept[] = $rule; + } + } + + $config->set('access_rules', array_values($kept))->save(); + + $this->messenger()->addStatus($this->t('Access rules updated.')); + } + +} diff --git a/src/GroupAccessRulesService.php b/src/GroupAccessRulesService.php new file mode 100644 index 0000000..7034d42 --- /dev/null +++ b/src/GroupAccessRulesService.php @@ -0,0 +1,276 @@ +configFactory = $config_factory; + $this->entityTypeManager = $entity_type_manager; + $this->configName = $configName; + } + + /** + * Check access for an operation on an existing entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being accessed. + * @param string $operation + * The operation: view, update, delete. + * @param \Drupal\Core\Session\AccountInterface $account + * The user account. + * + * @return \Drupal\Core\Access\AccessResult + * The access result. + */ + public function checkAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { + $rules = $this->getRulesForOperation( + $entity->getEntityTypeId(), + $entity->bundle(), + $operation + ); + + if (empty($rules)) { + return AccessResult::neutral(); + } + + $cache = (new CacheableMetadata()) + ->addCacheContexts(['user']) + ->addCacheableDependency($entity) + ->addCacheTags(['config:' . $this->configName]); + + foreach ($rules as $rule) { + // For update/delete/view, check optional field conditions on the entity. + if (!$this->entityMatchesConditions($entity, $rule['field_conditions'] ?? [])) { + continue; + } + + if ($this->userMatchesRule($account, $rule)) { + return AccessResult::allowed()->addCacheableDependency($cache); + } + + if (($rule['mode'] ?? 'restrictive') === 'restrictive') { + return AccessResult::forbidden()->addCacheableDependency($cache); + } + } + + return AccessResult::neutral()->addCacheableDependency($cache); + } + + /** + * Check create access for an entity type and bundle. + * + * Field conditions are not evaluated here because the entity does not yet + * exist at creation time. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The user account. + * @param string $entity_type + * The entity type ID. + * @param string $bundle + * The bundle. + * + * @return \Drupal\Core\Access\AccessResult + * The access result. + */ + public function checkCreateAccess(AccountInterface $account, string $entity_type, string $bundle): AccessResult { + $rules = $this->getRulesForOperation($entity_type, $bundle, 'create'); + + if (empty($rules)) { + return AccessResult::neutral(); + } + + $cache = (new CacheableMetadata()) + ->addCacheContexts(['user']) + ->addCacheTags(['config:' . $this->configName]); + + foreach ($rules as $rule) { + if ($this->userMatchesRule($account, $rule)) { + return AccessResult::allowed()->addCacheableDependency($cache); + } + + if (($rule['mode'] ?? 'restrictive') === 'restrictive') { + return AccessResult::forbidden()->addCacheableDependency($cache); + } + } + + return AccessResult::neutral()->addCacheableDependency($cache); + } + + /** + * Returns enabled rules that match the given entity type, bundle, and operation. + * + * @param string $entity_type + * Entity type ID. + * @param string $bundle + * Bundle machine name. + * @param string $operation + * Operation name (create, update, delete, view). + * + * @return array + * Matching rules. + */ + protected function getRulesForOperation(string $entity_type, string $bundle, string $operation): array { + $all_rules = $this->configFactory + ->get($this->configName) + ->get('access_rules') ?? []; + + return array_values(array_filter($all_rules, function (array $rule) use ($entity_type, $bundle, $operation) { + if (!($rule['enabled'] ?? TRUE)) { + return FALSE; + } + if (($rule['entity_type'] ?? '') !== $entity_type) { + return FALSE; + } + // An empty bundle in the rule means "all bundles of this entity type". + if (!empty($rule['bundle']) && $rule['bundle'] !== $bundle) { + return FALSE; + } + if (!in_array($operation, $rule['operations'] ?? [], TRUE)) { + return FALSE; + } + return TRUE; + })); + } + + /** + * Returns TRUE if the entity satisfies all field conditions of a rule. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check. + * @param array $conditions + * Array of conditions, each with 'field' and 'value' keys. + * + * @return bool + * TRUE if all conditions are met or there are no conditions. + */ + protected function entityMatchesConditions(EntityInterface $entity, array $conditions): bool { + foreach ($conditions as $condition) { + $field_name = $condition['field'] ?? ''; + $expected = (string) ($condition['value'] ?? ''); + + if (empty($field_name) || !$entity->hasField($field_name)) { + return FALSE; + } + + $field = $entity->get($field_name); + // Support simple value fields and entity reference fields. + $actual = (string) ($field->value ?? $field->target_id ?? ''); + + if ($actual !== $expected) { + return FALSE; + } + } + + return TRUE; + } + + /** + * Returns TRUE if the account satisfies the membership requirements of a rule. + * + * Each rule contains a `memberships` array where every entry pairs a group ID + * with its own set of required roles. A user is authorized if they match + * at least one entry: member of that group AND holding at least one of the + * entry's roles. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The account to check. + * @param array $rule + * The access rule configuration. + * + * @return bool + * TRUE if the user satisfies at least one membership+role requirement. + */ + protected function userMatchesRule(AccountInterface $account, array $rule): bool { + if (!$account->isAuthenticated()) { + return FALSE; + } + + $memberships = $rule['memberships'] ?? []; + if (empty($memberships)) { + return FALSE; + } + + try { + $storage = $this->entityTypeManager->getStorage('group'); + } + catch (\Exception $e) { + return FALSE; + } + + foreach ($memberships as $entry) { + $group_id = (int) ($entry['group_id'] ?? 0); + $required_roles = $entry['roles'] ?? []; + + if (!$group_id || empty($required_roles)) { + continue; + } + + $group = $storage->load($group_id); + if (!$group) { + continue; + } + + $membership = $group->getMember($account); + if (!$membership) { + continue; + } + + $member_role_ids = array_keys($membership->getRoles()); + foreach ($required_roles as $required_role) { + if (in_array($required_role, $member_role_ids, TRUE)) { + return TRUE; + } + } + } + + return FALSE; + } + +} diff --git a/translations/ldap_groups_sync.pt-br.po b/translations/ldap_groups_sync.pt-br.po new file mode 100644 index 0000000..0a5a989 --- /dev/null +++ b/translations/ldap_groups_sync.pt-br.po @@ -0,0 +1,193 @@ +# Portuguese (Brazil) translation for LDAP Groups Sync base module +# Copyright (c) 2026 +# This file is distributed under the same license as the LDAP Groups Sync module. +# +msgid "" +msgstr "" +"Project-Id-Version: LDAP Groups Sync 1.0.0\n" +"POT-Creation-Date: 2026-02-28 00:00+0000\n" +"PO-Revision-Date: 2026-02-28 00:00+0000\n" +"Language-Team: Portuguese (Brazil)\n" +"Language: pt-br\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: src/Form/AccessRulesFormBase.php + +msgid "Define rules that restrict or grant entity operations based on group membership.
Restrictive: if a matching rule exists and the user does not satisfy it, access is denied.
Additive: the rule only grants extra access; other users keep their default permissions.
Field conditions are only evaluated for update/delete/view (entity already exists)." +msgstr "Defina regras que restringem ou concedem operações em entidades com base em membros de grupo.
Restritiva: se uma regra correspondente existir e o usuário não a satisfizer, o acesso é negado.
Aditiva: a regra apenas concede acesso extra; outros usuários mantêm suas permissões padrão.
Condições de campo são avaliadas apenas para atualizar/excluir/visualizar (entidade já existe)." + +msgid "Label" +msgstr "Rótulo" + +msgid "Entity type / Bundle" +msgstr "Tipo de entidade / Bundle" + +msgid "Operations" +msgstr "Operações" + +msgid "Groups / Roles" +msgstr "Grupos / Papéis" + +msgid "Mode" +msgstr "Modo" + +msgid "Status" +msgstr "Status" + +msgid "Edit" +msgstr "Editar" + +msgid "Remove" +msgstr "Remover" + +msgid "No access rules defined. Click \"Add Rule\" to create the first one." +msgstr "Nenhuma regra de acesso definida. Clique em \"Adicionar Regra\" para criar a primeira." + +msgid "all bundles" +msgstr "todos os bundles" + +msgid "(no label)" +msgstr "(sem rótulo)" + +msgid "none" +msgstr "nenhum" + +msgid "Additive" +msgstr "Aditivo" + +msgid "Restrictive" +msgstr "Restritivo" + +msgid "On" +msgstr "Ativo" + +msgid "Off" +msgstr "Inativo" + +msgid "Edit Access Rule" +msgstr "Editar Regra de Acesso" + +msgid "Add Rule" +msgstr "Adicionar Regra" + +msgid "New Access Rule" +msgstr "Nova Regra de Acesso" + +msgid "Remove Selected" +msgstr "Remover Selecionados" + +msgid "Access rules updated." +msgstr "Regras de acesso atualizadas." + +#: src/Form/AccessRuleFormBase.php + +msgid "Human-readable description of this rule." +msgstr "Descrição legível desta regra." + +msgid "Enabled" +msgstr "Habilitado" + +msgid "Entity Type" +msgstr "Tipo de Entidade" + +msgid "- Select entity type -" +msgstr "- Selecione o tipo de entidade -" + +msgid "Bundle" +msgstr "Bundle" + +msgid "Leave empty to apply to all bundles of the selected entity type." +msgstr "Deixe vazio para aplicar a todos os bundles do tipo de entidade selecionado." + +msgid "- All bundles -" +msgstr "- Todos os bundles -" + +msgid "Create" +msgstr "Criar" + +msgid "Update" +msgstr "Atualizar" + +msgid "Delete" +msgstr "Excluir" + +msgid "View" +msgstr "Visualizar" + +msgid "Restrictive — deny access to users who do not match this rule" +msgstr "Restritivo — nega acesso a usuários que não correspondem a esta regra" + +msgid "Additive — only grant access; never deny other users" +msgstr "Aditivo — apenas concede acesso; nunca nega outros usuários" + +msgid "Membership requirements" +msgstr "Requisitos de associação" + +msgid "A user is authorized if they satisfy at least one card: member of that group and holding at least one of the checked roles. Each card can specify a different group and different roles." +msgstr "Um usuário é autorizado se satisfizer pelo menos um cartão: membro daquele grupo e possuindo ao menos um dos papéis marcados. Cada cartão pode especificar um grupo e papéis diferentes." + +msgid "Group" +msgstr "Grupo" + +msgid "- Select a group -" +msgstr "- Selecione um grupo -" + +msgid "Required roles (user must hold at least one)" +msgstr "Papéis obrigatórios (o usuário deve ter pelo menos um)" + +msgid "Remove this group" +msgstr "Remover este grupo" + +msgid "+ Add group" +msgstr "+ Adicionar grupo" + +msgid "Field conditions (optional — only evaluated for update/delete/view)" +msgstr "Condições de campo (opcional — avaliadas apenas para atualizar/excluir/visualizar)" + +msgid "All conditions must be true for the rule to apply to an existing entity. For create operations conditions are ignored." +msgstr "Todas as condições devem ser verdadeiras para que a regra se aplique a uma entidade existente. Para operações de criação, as condições são ignoradas." + +msgid "Field" +msgstr "Campo" + +msgid "Value" +msgstr "Valor" + +msgid "- Select field -" +msgstr "- Selecione o campo -" + +msgid "field_machine_name" +msgstr "field_machine_name" + +msgid "Expected value" +msgstr "Valor esperado" + +msgid "Add condition" +msgstr "Adicionar condição" + +msgid "Remove selected conditions" +msgstr "Remover condições selecionadas" + +msgid "Save rule" +msgstr "Salvar regra" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Select at least one operation." +msgstr "Selecione pelo menos uma operação." + +msgid "Add at least one group membership requirement." +msgstr "Adicione pelo menos um requisito de associação ao grupo." + +msgid "Select at least one required role for each group." +msgstr "Selecione pelo menos um papel obrigatório para cada grupo." + +msgid "Individual" +msgstr "Individual" + +msgid "Insider" +msgstr "Interno"